Repository: nipreps/nifreeze
Pull request #90: ENH: Add motion visualization helper routines
Author: login: jhlegarreta
Created: 2025-04-23T01:39:22Z | Merged: 2025-05-13T04:52:48Z

Add motion visualization helper routines.


Diff:
diff --git a/docs/notebooks/bold_realignment.ipynb b/docs/notebooks/bold_realignment.ipynb
index 658171c19..3f433bb89 100644
--- a/docs/notebooks/bold_realignment.ipynb
+++ b/docs/notebooks/bold_realignment.ipynb
@@ -179,6 +179,9 @@
    "metadata": {},
    "outputs": [],
    "source": [
+    "from nifreeze.viz.motion_viz import plot_framewise_displacement\n",
+    "\n",
+    "\n",
     "def plot_profile(image_path, axis=None, indexing=None, cmap=\"gray\", label=None, figsize=(15, 1.7)):\n",
     "    \"\"\"Plots a single image slice on a given axis or a new figure if axis is None.\"\"\"\n",
     "    # Load the image\n",
@@ -262,12 +265,9 @@
     "\n",
     "    # Plot the framewise displacement on the first axis\n",
     "    fd_axis = axes[0]\n",
-    "    timepoints = np.arange(len(afni_fd))  # Assuming afni_fd and nifreeze_fd have the same length\n",
-    "    fd_axis.plot(timepoints, afni_fd, label=\"AFNI 3dVolreg FD\", color=\"blue\")\n",
-    "    fd_axis.plot(timepoints, nifreeze_fd, label=\"nifreeze FD\", color=\"orange\")\n",
-    "    fd_axis.set_ylabel(\"FD (mm)\")\n",
-    "    fd_axis.legend(loc=\"upper right\")\n",
-    "    fd_axis.set_xticks([])  # Hide x-ticks to keep x-axis clean\n",
+    "    _ = plot_framewise_displacement(\n",
+    "        afni_fd, nifreeze_fd, \"AFNI 3dVolreg FD\", \"nifreeze FD\", ax=fd_axis\n",
+    "    )\n",
     "\n",
     "    # Set labels for profile plots if not provided\n",
     "    if labels is None or isinstance(labels, str):\n",
diff --git a/src/nifreeze/analysis/motion.py b/src/nifreeze/analysis/motion.py
new file mode 100644
index 000000000..80df87466
--- /dev/null
+++ b/src/nifreeze/analysis/motion.py
@@ -0,0 +1,59 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+import numpy as np
+
+
+def compute_percentage_change(
+    reference: np.ndarray,
+    test: np.ndarray,
+    mask: np.ndarray,
+) -> np.ndarray:
+    """Compute motion change between reference and test as a percentage.
+
+    If a mask is provided, the computation is only provided within the mask.
+    Also, null values are ignored.
+
+    Parameters
+    ----------
+    reference : :obj:`~numpy.ndarray`
+        Reference imaging volume.
+    test : :obj:`~numpy.ndarray`
+        Test (shifted) imaging volume.
+    mask : :obj:`~numpy.ndarray`
+        Mask for value consideration.
+
+    Returns
+    -------
+    rel_diff : :obj:`~numpy.ndarray`
+        Motion change between reference and test.
+    """
+
+    # Avoid divide-by-zero errors
+    eps = 1e-5
+    rel_diff = np.zeros_like(reference)
+    mask = mask.copy()
+    mask[reference <= eps] = False
+    rel_diff[mask] = 100 * (test[mask] - reference[mask]) / reference[mask]
+
+    return rel_diff
diff --git a/src/nifreeze/viz/motion_viz.py b/src/nifreeze/viz/motion_viz.py
new file mode 100644
index 000000000..fcf94de61
--- /dev/null
+++ b/src/nifreeze/viz/motion_viz.py
@@ -0,0 +1,241 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+from typing import Union
+
+import matplotlib.cm as cm
+import matplotlib.colors as mcolors
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+from matplotlib.axes import Axes
+from scipy.ndimage import gaussian_filter
+
+ORIENTATIONS = ["sagittal", "coronal", "axial"]
+
+
+def _extract_slice(img_data: np.ndarray, orientation: str, slice_idx: int) -> np.ndarray:
+    """Extract slice data from the given volume at the given orientation slice index.
+
+    Parameters
+    ----------
+    img_data : :obj:`~numpy.ndarray`
+        Image data to be sliced.
+    orientation : :obj:`str`
+        Orientation. Can be one of obj:`ORIENTATIONS`.
+    slice_idx : :obj:`int`
+        Slice index.
+
+    Returns
+    -------
+    :obj:`~numpy.ndarray`
+        Image slice.
+    """
+
+    axis = ORIENTATIONS.index(orientation)
+
+    axis_sizw = img_data.shape[axis]
+
+    if not (0 <= slice_idx < axis_sizw):
+        raise IndexError(
+            f"Slice index {slice_idx} out of bounds for axis {orientation} with size {axis_sizw}"
+        )
+
+    slice_obj: list[int | slice] = [slice(None)] * 3
+    slice_obj[axis] = slice_idx
+    slice_2d = img_data[tuple(slice_obj)]
+    return slice_2d if axis == 2 else np.rot90(slice_2d)
+
+
+def plot_framewise_displacement(
+    fd: pd.DataFrame,
+    labels: list,
+    cmap_name: str = "viridis",
+    ax: Union[Axes, None] = None,
+) -> Axes:
+    """Plot frame-wise displacement data.
+
+    Plots the frame-wise displacement data corresponding to different
+    realizations.
+
+    Parameters
+    ----------
+    fd : :obj:`~pd.DataFrame`
+        Frame-wise displacement values corresponding.
+    labels : :obj:`list`
+        Labels for legend.
+    cmap_name : str, optional
+        Colormap name.
+    ax : :obj:`Axes`, optional
+        Figure axes.
+
+    Returns
+    -------
+    ax : :obj:`Axes`
+        Figure plot axis.
+    """
+
+    n_cols = len(fd.columns)
+    n_labels = len(labels)
+
+    if n_cols != n_labels:
+        raise ValueError(
+            f"The number of realizations and labels does not match: {n_cols}; {n_labels}"
+        )
+
+    if ax is None:
+        fig, ax = plt.subplots(1, 1, figsize=(10, 6), constrained_layout=True)
+
+    # Plot the framewise displacement
+    n_frames = fd.index.to_numpy()
+
+    cmap = cm.get_cmap(cmap_name, n_cols)
+    colors = [mcolors.to_hex(cmap(i)) for i in range(n_cols)]
+
+    for i, col in enumerate(fd.columns):
+        ax.plot(n_frames, fd[col], label=labels[i], color=colors[i])
+
+    ax.set_ylabel("FD (mm)")
+    ax.legend(loc="upper right")
+    ax.set_xticks([])  # Hide x-ticks to keep x-axis clean
+
+    return ax
+
+
+def plot_volumewise_motion(
+    frames: np.ndarray,
+    motion_params: np.ndarray,
+    ax: np.ndarray | None = None,
+) -> np.ndarray:
+    """Plot mean volume-wise motion parameters.
+
+    Plots the mean translation and rotation parameters along the ``x``, `y``,
+    and ``z`` axes.
+
+    Parameters
+    ----------
+    frames : :obj:`~numpy.ndarray`
+        Frame indices.
+    motion_params : :obj:`~numpy.ndarray`
+        Motion parameters.Motion parameters: translation and rotation. Each row
+        represents one frame, and columns represent each coordinate axis ``x``,
+        `y``, and ``z``. Translation parameters are followed by rotation
+        parameters column-wise.
+    ax : :obj:`~numpy.ndarray`, optional
+        Figure axes.
+
+    Returns
+    -------
+    ax : :obj:`~numpy.ndarray`
+        Figure plot axes array.
+    """
+
+    if ax is None:
+        fig, ax = plt.subplots(2, 1, figsize=(10, 6), sharex=True, constrained_layout=True)
+
+    # Plot translations
+    ax[0].plot(frames, motion_params[:, 0], label="x")
+    ax[0].plot(frames, motion_params[:, 1], label="y")
+    ax[0].plot(frames, motion_params[:, 2], label="z")
+    ax[0].set_ylabel("Translation (mm)")
+    ax[0].legend(loc="upper right")
+    ax[0].set_title("Translation vs frames")
+
+    # Plot rotations
+    ax[1].plot(frames, motion_params[:, 3], label="Rx")
+    ax[1].plot(frames, motion_params[:, 4], label="Ry")
+    ax[1].plot(frames, motion_params[:, 5], label="Rz")
+    ax[1].set_ylabel("Rotation (deg)")
+    ax[1].set_xlabel("Time (s)")
+    ax[1].legend(loc="upper right")
+    ax[1].set_title("Rotation vs frames")
+
+    return ax
+
+
+def plot_motion_overlay(
+    rel_diff: np.ndarray,
+    img_data: np.ndarray,
+    brain_mask: np.ndarray,
+    orientation: str,
+    slice_idx: int,
+    smooth: bool = True,
+    ax: Union[Axes, None] = None,
+) -> Axes:
+    """Plot motion relative difference as an overlay on a given orientation and slice of the imaging data.
+
+    The values of the relative difference can optionally be smoothed using a
+    Gaussian filter for a more appealing visual result.
+
+    Parameters
+    ----------
+    rel_diff : :obj:`~numpy.ndarray`
+        Relative motion difference.
+    img_data : :obj:`~numpy.ndarray`
+        Imaging data.
+    brain_mask : :obj:`~numpy.ndarray`
+        Brain mask.
+    orientation : :obj:`str`
+        Orientation. Can be one of obj:`ORIENTATIONS`.
+    slice_idx : :obj:`int`
+        Slice index to be plot.
+    smooth : :obj:`bool`, optional
+        ``True`` to smooth the motion relative difference.
+    ax : :obj:`Axes`, optional
+        Figure axis.
+
+    Returns
+    -------
+    ax : :obj:`Axes`
+        Figure plot axis.
+    """
+
+    # Check dimensionality
+    if img_data.shape != rel_diff.shape:
+        raise IndexError(
+            f"Dimension mismatch: imaging data shape {img_data.shape}, overlay shape {rel_diff.shape}"
+        )
+
+    # Smooth the relative difference
+    smoothed_diff = rel_diff
+    if smooth:
+        smoothed_diff = gaussian_filter(rel_diff, sigma=1)
+
+    # Mask the background
+    masked_img_data = np.where(brain_mask, img_data, np.nan)
+    masked_smooth_diff = np.where(brain_mask, smoothed_diff, np.nan)
+
+    masked_img_slice = _extract_slice(masked_img_data, orientation, slice_idx)
+    diff_img_slice = _extract_slice(masked_smooth_diff, orientation, slice_idx)
+
+    # Show overlay on a slice
+    if ax is None:
+        fig, ax = plt.subplots(1, 1, figsize=(10, 5), constrained_layout=True)
+
+    ax.imshow(masked_img_slice, cmap="gray")
+    im = ax.imshow(diff_img_slice, cmap="bwr", alpha=0.5)
+    ax.figure.colorbar(im, ax=ax, label="Relative Difference (%)")
+    ax.set_title("Smoothed Relative Difference Overlay")
+    ax.axis("off")
+
+    return ax
diff --git a/test/test_motion_viz.py b/test/test_motion_viz.py
new file mode 100644
index 000000000..4d9a11a5a
--- /dev/null
+++ b/test/test_motion_viz.py
@@ -0,0 +1,158 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import pytest
+from dipy.data import fetch_stanford_hardi, read_stanford_hardi
+from dipy.segment.mask import median_otsu
+from scipy.ndimage import affine_transform, binary_dilation
+from skimage.morphology import ball
+
+from nifreeze.analysis.motion import compute_percentage_change
+from nifreeze.viz.motion_viz import (
+    ORIENTATIONS,
+    plot_framewise_displacement,
+    plot_motion_overlay,
+    plot_volumewise_motion,
+)
+
+if not (Path.home() / ".dipy" / "stanfoed_hardi").exists():
+    fetch_stanford_hardi()
+
+img, _ = read_stanford_hardi()
+img_data = img.get_fdata()
+
+
+def test_plot_framewise_displacement(request, tmp_path):
+    rng = request.node.rng
+
+    n_frames = 100
+    low = 0.0
+    high = 1.0
+
+    # Simulate framewise_displacement for a given number of frames
+    fd1 = rng.uniform(low=low, high=high, size=n_frames)
+    fd2 = rng.uniform(low=low, high=high, size=n_frames)
+
+    fd = pd.DataFrame({"fd1": fd1, "fd2": fd2})
+
+    labels = ["AFNI 3dVolreg FD", "nifreeze FD", "foo"]
+
+    # Test appropriate dimensionality of data
+    with pytest.raises(ValueError):
+        _ = plot_framewise_displacement(fd, labels)
+
+    labels = labels[:-1]
+    ax = plot_framewise_displacement(fd, labels)
+    out_svg = tmp_path / "framewise_displacement.svg"
+    ax.figure.savefig(out_svg, format="svg")
+
+
+def test_plot_volumewise_motion(request, tmp_path):
+    rng = request.node.rng
+
+    # Simulate motion for a given number of frames
+    n_frames = 100
+    frames = np.arange(n_frames)
+
+    # Simulated translations (in mm)
+    translations = rng.standard_normal((n_frames, 3)).cumsum(axis=0) * 0.2
+
+    # Simulated rotations (in degrees)
+    rotations = rng.standard_normal((n_frames, 3)).cumsum(axis=0) * 0.1
+
+    # Combine into one motion matrix: shape (n_frames, 6)
+    motion_params = np.hstack([translations, rotations])
+
+    ax = plot_volumewise_motion(frames, motion_params)
+    out_svg = tmp_path / "volumewise_motion.svg"
+    ax[0].figure.savefig(out_svg, format="svg")
+
+
+@pytest.mark.parametrize("orientation", ["axial", "coronal", "sagittal"])
+def test_plot_motion_overlay(tmp_path, orientation):
+    _, brain_mask = median_otsu(img_data, vol_idx=[0])
+    brain_mask = binary_dilation(brain_mask, ball(2))
+
+    # Create an affine transformation (rotation + translation)
+    theta = np.deg2rad(5)  # 5 degree rotation
+    cos_t, sin_t = np.cos(theta), np.sin(theta)
+
+    # Rotate around Z axis and shift by +5 in x and -3 in y
+    rotation_matrix = np.array([[cos_t, -sin_t, 0], [sin_t, cos_t, 0], [0, 0, 1]])
+    translation = np.array([5, -3, 0])  # in voxel units
+
+    # Transform matrix
+    transform = np.eye(4)
+    transform[:3, :3] = rotation_matrix
+    transform[:3, 3] = translation
+
+    # Inverse affine because scipy applies the inverse
+    inv_transform = np.linalg.inv(transform)
+
+    dwi_dir_data = img_data[..., 1]
+
+    # Apply the transformation
+    shifted = affine_transform(
+        dwi_dir_data, inv_transform[:3, :3], offset=inv_transform[:3, 3], order=1
+    )
+
+    # Compute relative difference
+    rel_diff = compute_percentage_change(dwi_dir_data, shifted, brain_mask)
+
+    # Clip values for visualization purposes
+    rel_diff = np.clip(rel_diff, -10, 10)
+
+    smooth = True
+    axis = ORIENTATIONS.index(orientation)
+    slice_idx = img_data.shape[axis] // 2
+
+    with pytest.raises(IndexError):
+        _ = plot_motion_overlay(
+            rel_diff[..., np.newaxis],
+            dwi_dir_data,
+            brain_mask,
+            orientation,
+            slice_idx,
+            smooth=smooth,
+        )
+
+    _slice_idx = img_data.shape[axis]
+    with pytest.raises(IndexError):
+        _ = plot_motion_overlay(
+            rel_diff,
+            dwi_dir_data,
+            brain_mask,
+            orientation,
+            _slice_idx,
+            smooth=smooth,
+        )
+
+    ax = plot_motion_overlay(
+        rel_diff, dwi_dir_data, brain_mask, orientation, slice_idx, smooth=smooth
+    )
+    out_svg = tmp_path / "motion_overlay.svg"
+    ax.figure.savefig(out_svg, format="svg")


Reviews:
user: login: jhlegarreta
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T03:01:17Z
user: login: oesteban
body: Left some suggestions, but looking great!
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T00:37:09Z
user: login: jhlegarreta
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T02:01:41Z
user: login: jhlegarreta
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T03:15:57Z
user: login: jhlegarreta
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T03:16:22Z

Review comments:
diff_hunk: @@ -0,0 +1,267 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+from typing import Union
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib.axes import Axes
+from scipy.ndimage import gaussian_filter
+
+
+def _get_axis(orientation: str):
+    """Get axis for the given orientation.
+
+    Parameters
+    ----------
+    orientation : :obj:`str`
+        Orientation. Cn be one of ``axial``, ``coronal``, or ``sagittal``.
+
+    Returns
+    -------
+    :obj:`int`
+        Axis corresponding to the given orientation.
+    """
+
+    if orientation == "axial":
+        return 2
+    elif orientation == "coronal":
+        return 1
+    elif orientation == "sagittal":
+        return 0
+    else:
+        raise ValueError(
+            f"Orientation must be one of 'axial', 'coronal', 'sagittal'. Provided: {orientation}"
+        )
+
+
+def _extract_slice(img_data: np.ndarray, orientation: str, slice_idx: int):
+    """Extract slice data from the given volume at the given orientation slice index.
+
+    Parameters
+    ----------
+    img_data : :obj:`~numpy.ndarray`
+        Image data to be sliced.
+    orientation : :obj:`str`
+        Orientation.
+    slice_idx : :obj:`int`
+        Slice index.
+
+    Returns
+    -------
+    :obj:`~numpy.ndarray`
+        Image slice.
+    """
+
+    axis = _get_axis(orientation)
+
+    axis_sizw = img_data.shape[axis]
+
+    if not (0 <= slice_idx < axis_sizw):
+        raise IndexError(
+            f"Slice index {slice_idx} out of bounds for axis {orientation} with size {axis_sizw}"
+        )
+
+    if axis == 2:
+        return img_data[:, :, slice_idx]
+    elif axis == 1:
+        return np.rot90(img_data[:, slice_idx, :])
+    else:
+        return np.rot90(img_data[slice_idx, :, :])
+
+
+def plot_framewise_displacement(
+    fd1: np.ndarray,
+    fd2: np.ndarray,
+    label1: str,
+    label2: str,
+    ax: Union[Axes, None] = None,
+) -> Axes:
+    """Plot frame-wise displacement data.
+
+    Plots the frame-wise displacement data corresponding to two different
+    realizations.
+
+    Parameters
+    ----------
+    fd1 : :obj:`~numpy.ndarray`
+        Frame-wise displacement values corresponding to first realization.
+    fd2 : :obj:`~numpy.ndarray`
+        Frame-wise displacement values corresponding to second realization.
+    label1 : :obj:`str`
+        Label for the first realization legend.
+    label2 : :obj:`str`
+        Label for the second realization legend.
+    ax : :obj:`Axes`, optional
+        Figure axes.
+
+    Returns
+    -------
+    ax : :obj:`Axes`
+        Figure plot axis.
+    """
+
+    fd1_dims = fd1.ndim
+    fd2_dims = fd2.ndim
+
+    n_frames_fd1 = fd1.shape
+    n_frames_fd2 = fd2.shape
+
+    req_dims = 1
+    if fd1_dims != req_dims or fd2_dims != req_dims:
+        raise ValueError(f"fd1 and fd2 must be 1D: {n_frames_fd1}; {n_frames_fd2}")
+
+    if n_frames_fd1 != n_frames_fd2:
+        raise ValueError(f"fd1 and fd2 must have same shape: {n_frames_fd1}; {n_frames_fd2}")
+
+    if ax is None:
+        fig, ax = plt.subplots(1, 1, figsize=(10, 6), constrained_layout=True)
+
+    # Plot the framewise displacement
+    n_frames = np.arange(len(fd1))
+    ax.plot(n_frames, fd1, label=label1, color="blue")
+    ax.plot(n_frames, fd2, label=label2, color="orange")
+    ax.set_ylabel("FD (mm)")
+    ax.legend(loc="upper right")
+    ax.set_xticks([])  # Hide x-ticks to keep x-axis clean
+
+    return ax
+
+
+def plot_volumewise_motion(
+    frames: np.ndarray,
+    motion_params: np.ndarray,
+    ax: np.ndarray | None = None,
+) -> np.ndarray:
+    """Plot mean volume-wise motion parameters.
+
+    Plots the mean translation and rotation parameters along the ``x``, `y``,
+    and ``z`` axes.
+
+    Parameters
+    ----------
+    frames : :obj:`~numpy.ndarray`
+        Frame indices.
+    motion_params : :obj:`~numpy.ndarray`
+        Motion parameters.Motion parameters: translation and rotation. Each row
+        represents one frame, and columns represent each coordinate axis ``x``,
+        `y``, and ``z``. Translation parameters are followed by rotation
+        parameters column-wise.
+    ax : :obj:`~numpy.ndarray`, optional
+        Figure axes.
+
+    Returns
+    -------
+    ax : :obj:`~numpy.ndarray`
+        Figure plot axes array.
+    """
+
+    if ax is None:
+        fig, ax = plt.subplots(2, 1, figsize=(10, 6), sharex=True, constrained_layout=True)
+
+    # Plot translations
+    ax[0].plot(frames, motion_params[:, 0], label="x")
+    ax[0].plot(frames, motion_params[:, 1], label="y")
+    ax[0].plot(frames, motion_params[:, 2], label="z")
+    ax[0].set_ylabel("Translation (mm)")
path: src/nifreeze/viz/motion_viz.py
user: login: jhlegarreta
body: Here Bastiani et al. 2019 use std values; maybe labels could be parameters, or we should compute std values inside the function?
created_at: 2025-05-11T03:01:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+import numpy as np
+
+
+def compute_relative_difference(data, shifted, mask):
path: src/nifreeze/analysis/motion.py
user: login: oesteban
body: Can you add a docstring to this function?
created_at: 2025-05-12T00:07:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+import numpy as np
+
+
+def compute_relative_difference(data, shifted, mask):
+    # Avoid divide-by-zero errors
+    eps = 1e-5
+    rel_diff = np.zeros_like(data)
+    rel_diff[mask] = 100 * (data[mask] - shifted[mask]) / (data[mask] + eps)
path: src/nifreeze/analysis/motion.py
user: login: oesteban
body: I would leave zeros out, otherwise, you'll get enormous values at those zeros:

```suggestion
    mask = mask.copy()
    mask[data <= eps] = False
    rel_diff[mask] = 100 * (test[mask] - reference[mask]) / reference[mask]
```
created_at: 2025-05-12T00:10:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+import numpy as np
+
+
+def compute_relative_difference(data, shifted, mask):
+    # Avoid divide-by-zero errors
+    eps = 1e-5
+    rel_diff = np.zeros_like(data)
+    rel_diff[mask] = 100 * (data[mask] - shifted[mask]) / (data[mask] + eps)
+
+    # ToDo
+    # Think if this should be done in the visualization function
+    rel_diff = np.clip(rel_diff, -10, 10)
path: src/nifreeze/analysis/motion.py
user: login: oesteban
body: It does feel like a visualization / interpretation option.
created_at: 2025-05-12T00:11:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,267 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+from typing import Union
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib.axes import Axes
+from scipy.ndimage import gaussian_filter
+
+
+def _get_axis(orientation: str):
+    """Get axis for the given orientation.
+
+    Parameters
+    ----------
+    orientation : :obj:`str`
+        Orientation. Cn be one of ``axial``, ``coronal``, or ``sagittal``.
+
+    Returns
+    -------
+    :obj:`int`
+        Axis corresponding to the given orientation.
+    """
+
+    if orientation == "axial":
+        return 2
+    elif orientation == "coronal":
+        return 1
+    elif orientation == "sagittal":
+        return 0
+    else:
+        raise ValueError(
+            f"Orientation must be one of 'axial', 'coronal', 'sagittal'. Provided: {orientation}"
+        )
+
+
+def _extract_slice(img_data: np.ndarray, orientation: str, slice_idx: int):
+    """Extract slice data from the given volume at the given orientation slice index.
+
+    Parameters
+    ----------
+    img_data : :obj:`~numpy.ndarray`
+        Image data to be sliced.
+    orientation : :obj:`str`
+        Orientation.
+    slice_idx : :obj:`int`
+        Slice index.
+
+    Returns
+    -------
+    :obj:`~numpy.ndarray`
+        Image slice.
+    """
+
+    axis = _get_axis(orientation)
path: src/nifreeze/viz/motion_viz.py
user: login: oesteban
body: ```suggestion
    axis = ORIENTATIONS.index(orientation)
```

where orientations is a list of strings.
created_at: 2025-05-12T00:13:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,267 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+from typing import Union
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib.axes import Axes
+from scipy.ndimage import gaussian_filter
+
+
+def _get_axis(orientation: str):
+    """Get axis for the given orientation.
+
+    Parameters
+    ----------
+    orientation : :obj:`str`
+        Orientation. Cn be one of ``axial``, ``coronal``, or ``sagittal``.
+
+    Returns
+    -------
+    :obj:`int`
+        Axis corresponding to the given orientation.
+    """
+
+    if orientation == "axial":
+        return 2
+    elif orientation == "coronal":
+        return 1
+    elif orientation == "sagittal":
+        return 0
+    else:
+        raise ValueError(
+            f"Orientation must be one of 'axial', 'coronal', 'sagittal'. Provided: {orientation}"
+        )
path: src/nifreeze/viz/motion_viz.py
user: login: oesteban
body: ```suggestion
ORIENTATIONS = ["saggital", "coronal", "axial"]
```
created_at: 2025-05-12T00:14:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,162 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+import numpy as np
+import pytest
+from dipy.data import fetch_stanford_hardi, read_stanford_hardi
+from dipy.segment.mask import median_otsu
+from scipy.ndimage import affine_transform, binary_dilation
+from skimage.morphology import ball
+
+from nifreeze.analysis.motion import compute_relative_difference
+from nifreeze.viz.motion_viz import (
+    _get_axis,
+    plot_framewise_displacement,
+    plot_motion_overlay,
+    plot_volumewise_motion,
+)
+
+fetch_stanford_hardi()
+img, _ = read_stanford_hardi()
+img_data = img.get_fdata()
+
+
+def test_plot_framewise_displacement(request, tmp_path):
+    rng = request.node.rng
+
+    n_frames = 100
+    low = 0.0
+    high = 1.0
+
+    label1 = "AFNI 3dVolreg FD"
+    label2 = "nifreeze FD"
+
+    # Test appropriate dimensionality of data
+    with pytest.raises(ValueError):
+        fd1 = rng.uniform(low=low, high=high, size=(n_frames, 2))
+        fd2 = rng.uniform(low=low, high=high, size=n_frames)
+        plot_framewise_displacement(fd1, fd2, label1, label2)
+
+        fd1 = rng.uniform(low=low, high=high, size=n_frames)
+        fd2 = rng.uniform(low=low, high=high, size=(n_frames, 2))
+        plot_framewise_displacement(fd1, fd2, label1, label2)
+
+        fd1 = rng.uniform(low=low, high=high, size=n_frames + 1)
+        fd2 = rng.uniform(low=low, high=high, size=n_frames)
+        plot_framewise_displacement(fd1, fd2, label1, label2)
+
+        fd1 = rng.uniform(low=low, high=high, size=n_frames)
+        fd2 = rng.uniform(low=low, high=high, size=n_frames + 1)
+        plot_framewise_displacement(fd1, fd2, label1, label2)
+
+    # Simulate framewise_displacement for a given number of frames
+    fd1 = rng.uniform(low=low, high=high, size=n_frames)
+    fd2 = rng.uniform(low=low, high=high, size=n_frames)
+
+    ax = plot_framewise_displacement(fd1, fd2, label1, label2)
+    out_svg = tmp_path / "framewise_displacement.svg"
+    ax.figure.savefig(out_svg, format="svg")
+
+
+def test_plot_volumewise_motion(request, tmp_path):
+    rng = request.node.rng
+
+    # Simulate motion for a given number of frames
+    n_frames = 100
+    frames = np.arange(n_frames)
+
+    # Simulated translations (in mm)
+    translations = rng.standard_normal((n_frames, 3)).cumsum(axis=0) * 0.2
+
+    # Simulated rotations (in degrees)
+    rotations = rng.standard_normal((n_frames, 3)).cumsum(axis=0) * 0.1
+
+    # Combine into one motion matrix: shape (n_frames, 6)
+    motion_params = np.hstack([translations, rotations])
+
+    ax = plot_volumewise_motion(frames, motion_params)
+    out_svg = tmp_path / "volumewise_motion.svg"
+    ax[0].figure.savefig(out_svg, format="svg")
+
+
+@pytest.mark.parametrize("orientation", ["axial", "coronal", "sagittal"])
+def test_plot_motion_overlay(tmp_path, orientation):
+    _, brain_mask = median_otsu(img_data, vol_idx=[0])
+    brain_mask = binary_dilation(brain_mask, ball(2))
+
+    # Create an affine transformation (rotation + translation)
+    theta = np.deg2rad(5)  # 5 degree rotation
+    cos_t, sin_t = np.cos(theta), np.sin(theta)
+
+    # Rotate around Z axis and shift by +5 in x and -3 in y
+    rotation_matrix = np.array([[cos_t, -sin_t, 0], [sin_t, cos_t, 0], [0, 0, 1]])
+    translation = np.array([5, -3, 0])  # in voxel units
+
+    # Transform matrix
+    transform = np.eye(4)
+    transform[:3, :3] = rotation_matrix
+    transform[:3, 3] = translation
+
+    # Inverse affine because scipy applies the inverse
+    inv_transform = np.linalg.inv(transform)
+
+    dwi_dir_data = img_data[..., 1]
+
+    # Apply the transformation
+    shifted = affine_transform(
+        dwi_dir_data, inv_transform[:3, :3], offset=inv_transform[:3, 3], order=1
+    )
+
+    # Compute relative difference
+    rel_diff = compute_relative_difference(dwi_dir_data, shifted, brain_mask)
+
+    smooth = True
+    axis = _get_axis(orientation)
path: test/test_motion_viz.py
user: login: oesteban
body: ```suggestion
    axis = ORIENTATIONS.index(orientation)
```
created_at: 2025-05-12T00:14:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,162 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+import numpy as np
+import pytest
+from dipy.data import fetch_stanford_hardi, read_stanford_hardi
+from dipy.segment.mask import median_otsu
+from scipy.ndimage import affine_transform, binary_dilation
+from skimage.morphology import ball
+
+from nifreeze.analysis.motion import compute_relative_difference
+from nifreeze.viz.motion_viz import (
+    _get_axis,
path: test/test_motion_viz.py
user: login: oesteban
body: ```suggestion
    ORIENTATIONS,
```
created_at: 2025-05-12T00:15:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+import numpy as np
+
+
+def compute_relative_difference(data, shifted, mask):
path: src/nifreeze/analysis/motion.py
user: login: oesteban
body: Also?
```suggestion
def compute_percentage_change(reference, test, mask):
```
created_at: 2025-05-12T00:17:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+import numpy as np
+
+
+def compute_relative_difference(data, shifted, mask):
+    # Avoid divide-by-zero errors
+    eps = 1e-5
+    rel_diff = np.zeros_like(data)
path: src/nifreeze/analysis/motion.py
user: login: oesteban
body: ```suggestion
    rel_diff = np.zeros_like(reference)
```
created_at: 2025-05-12T00:17:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,267 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+from typing import Union
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib.axes import Axes
+from scipy.ndimage import gaussian_filter
+
+
+def _get_axis(orientation: str):
+    """Get axis for the given orientation.
+
+    Parameters
+    ----------
+    orientation : :obj:`str`
+        Orientation. Cn be one of ``axial``, ``coronal``, or ``sagittal``.
+
+    Returns
+    -------
+    :obj:`int`
+        Axis corresponding to the given orientation.
+    """
+
+    if orientation == "axial":
+        return 2
+    elif orientation == "coronal":
+        return 1
+    elif orientation == "sagittal":
+        return 0
+    else:
+        raise ValueError(
+            f"Orientation must be one of 'axial', 'coronal', 'sagittal'. Provided: {orientation}"
+        )
+
+
+def _extract_slice(img_data: np.ndarray, orientation: str, slice_idx: int):
+    """Extract slice data from the given volume at the given orientation slice index.
+
+    Parameters
+    ----------
+    img_data : :obj:`~numpy.ndarray`
+        Image data to be sliced.
+    orientation : :obj:`str`
+        Orientation.
+    slice_idx : :obj:`int`
+        Slice index.
+
+    Returns
+    -------
+    :obj:`~numpy.ndarray`
+        Image slice.
+    """
+
+    axis = _get_axis(orientation)
+
+    axis_sizw = img_data.shape[axis]
+
+    if not (0 <= slice_idx < axis_sizw):
+        raise IndexError(
+            f"Slice index {slice_idx} out of bounds for axis {orientation} with size {axis_sizw}"
+        )
+
+    if axis == 2:
+        return img_data[:, :, slice_idx]
+    elif axis == 1:
+        return np.rot90(img_data[:, slice_idx, :])
+    else:
+        return np.rot90(img_data[slice_idx, :, :])
path: src/nifreeze/viz/motion_viz.py
user: login: oesteban
body: better without branching:

```suggestion
    slice_obj = [np.s_[:]] * 3
    slice_obj[axis] = slice_idx
    return (
        img_data[*slice_obj] if axis == 2
        else np.rot90(img_data[*slice_obj])
    )
```
created_at: 2025-05-12T00:29:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,267 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+from typing import Union
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib.axes import Axes
+from scipy.ndimage import gaussian_filter
+
+
+def _get_axis(orientation: str):
+    """Get axis for the given orientation.
+
+    Parameters
+    ----------
+    orientation : :obj:`str`
+        Orientation. Cn be one of ``axial``, ``coronal``, or ``sagittal``.
+
+    Returns
+    -------
+    :obj:`int`
+        Axis corresponding to the given orientation.
+    """
+
+    if orientation == "axial":
+        return 2
+    elif orientation == "coronal":
+        return 1
+    elif orientation == "sagittal":
+        return 0
+    else:
+        raise ValueError(
+            f"Orientation must be one of 'axial', 'coronal', 'sagittal'. Provided: {orientation}"
+        )
+
+
+def _extract_slice(img_data: np.ndarray, orientation: str, slice_idx: int):
+    """Extract slice data from the given volume at the given orientation slice index.
+
+    Parameters
+    ----------
+    img_data : :obj:`~numpy.ndarray`
+        Image data to be sliced.
+    orientation : :obj:`str`
+        Orientation.
+    slice_idx : :obj:`int`
+        Slice index.
+
+    Returns
+    -------
+    :obj:`~numpy.ndarray`
+        Image slice.
+    """
+
+    axis = _get_axis(orientation)
+
+    axis_sizw = img_data.shape[axis]
+
+    if not (0 <= slice_idx < axis_sizw):
+        raise IndexError(
+            f"Slice index {slice_idx} out of bounds for axis {orientation} with size {axis_sizw}"
+        )
+
+    if axis == 2:
+        return img_data[:, :, slice_idx]
+    elif axis == 1:
+        return np.rot90(img_data[:, slice_idx, :])
+    else:
+        return np.rot90(img_data[slice_idx, :, :])
+
+
+def plot_framewise_displacement(
+    fd1: np.ndarray,
+    fd2: np.ndarray,
+    label1: str,
+    label2: str,
path: src/nifreeze/viz/motion_viz.py
user: login: oesteban
body: Can we make this method take a pandas dataframe instead (so the column names are kept and used as labels) so we can plot an arbitrary number of compared FD traces?

Otherwise, without pandas (also good option IMHO) we can make the FD be an TxN (N=number of FD series, T=time) matrix.

```suggestion
    fd_series: np.ndarray,
    columns: list[str],
```
created_at: 2025-05-12T00:34:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,267 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+from typing import Union
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib.axes import Axes
+from scipy.ndimage import gaussian_filter
+
+
+def _get_axis(orientation: str):
+    """Get axis for the given orientation.
+
+    Parameters
+    ----------
+    orientation : :obj:`str`
+        Orientation. Cn be one of ``axial``, ``coronal``, or ``sagittal``.
+
+    Returns
+    -------
+    :obj:`int`
+        Axis corresponding to the given orientation.
+    """
+
+    if orientation == "axial":
+        return 2
+    elif orientation == "coronal":
+        return 1
+    elif orientation == "sagittal":
+        return 0
+    else:
+        raise ValueError(
+            f"Orientation must be one of 'axial', 'coronal', 'sagittal'. Provided: {orientation}"
+        )
+
+
+def _extract_slice(img_data: np.ndarray, orientation: str, slice_idx: int):
+    """Extract slice data from the given volume at the given orientation slice index.
+
+    Parameters
+    ----------
+    img_data : :obj:`~numpy.ndarray`
+        Image data to be sliced.
+    orientation : :obj:`str`
+        Orientation.
+    slice_idx : :obj:`int`
+        Slice index.
+
+    Returns
+    -------
+    :obj:`~numpy.ndarray`
+        Image slice.
+    """
+
+    axis = _get_axis(orientation)
+
+    axis_sizw = img_data.shape[axis]
+
+    if not (0 <= slice_idx < axis_sizw):
+        raise IndexError(
+            f"Slice index {slice_idx} out of bounds for axis {orientation} with size {axis_sizw}"
+        )
+
+    if axis == 2:
+        return img_data[:, :, slice_idx]
+    elif axis == 1:
+        return np.rot90(img_data[:, slice_idx, :])
+    else:
+        return np.rot90(img_data[slice_idx, :, :])
+
+
+def plot_framewise_displacement(
+    fd1: np.ndarray,
+    fd2: np.ndarray,
+    label1: str,
+    label2: str,
+    ax: Union[Axes, None] = None,
+) -> Axes:
+    """Plot frame-wise displacement data.
+
+    Plots the frame-wise displacement data corresponding to two different
+    realizations.
+
+    Parameters
+    ----------
+    fd1 : :obj:`~numpy.ndarray`
+        Frame-wise displacement values corresponding to first realization.
+    fd2 : :obj:`~numpy.ndarray`
+        Frame-wise displacement values corresponding to second realization.
+    label1 : :obj:`str`
+        Label for the first realization legend.
+    label2 : :obj:`str`
+        Label for the second realization legend.
+    ax : :obj:`Axes`, optional
+        Figure axes.
+
+    Returns
+    -------
+    ax : :obj:`Axes`
+        Figure plot axis.
+    """
+
+    fd1_dims = fd1.ndim
+    fd2_dims = fd2.ndim
+
+    n_frames_fd1 = fd1.shape
+    n_frames_fd2 = fd2.shape
+
+    req_dims = 1
+    if fd1_dims != req_dims or fd2_dims != req_dims:
+        raise ValueError(f"fd1 and fd2 must be 1D: {n_frames_fd1}; {n_frames_fd2}")
+
+    if n_frames_fd1 != n_frames_fd2:
+        raise ValueError(f"fd1 and fd2 must have same shape: {n_frames_fd1}; {n_frames_fd2}")
path: src/nifreeze/viz/motion_viz.py
user: login: oesteban
body: If they come in a single numpy 2D array, this is not necessary anymore.
created_at: 2025-05-12T00:35:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+import numpy as np
+
+
+def compute_relative_difference(data, shifted, mask):
+    # Avoid divide-by-zero errors
+    eps = 1e-5
+    rel_diff = np.zeros_like(data)
+    rel_diff[mask] = 100 * (data[mask] - shifted[mask]) / (data[mask] + eps)
+
+    # ToDo
+    # Think if this should be done in the visualization function
+    rel_diff = np.clip(rel_diff, -10, 10)
path: src/nifreeze/analysis/motion.py
user: login: jhlegarreta
body: Transferred to the test in a1a9140.
created_at: 2025-05-12T02:01:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,267 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+from typing import Union
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib.axes import Axes
+from scipy.ndimage import gaussian_filter
+
+
+def _get_axis(orientation: str):
+    """Get axis for the given orientation.
+
+    Parameters
+    ----------
+    orientation : :obj:`str`
+        Orientation. Cn be one of ``axial``, ``coronal``, or ``sagittal``.
+
+    Returns
+    -------
+    :obj:`int`
+        Axis corresponding to the given orientation.
+    """
+
+    if orientation == "axial":
+        return 2
+    elif orientation == "coronal":
+        return 1
+    elif orientation == "sagittal":
+        return 0
+    else:
+        raise ValueError(
+            f"Orientation must be one of 'axial', 'coronal', 'sagittal'. Provided: {orientation}"
+        )
+
+
+def _extract_slice(img_data: np.ndarray, orientation: str, slice_idx: int):
+    """Extract slice data from the given volume at the given orientation slice index.
+
+    Parameters
+    ----------
+    img_data : :obj:`~numpy.ndarray`
+        Image data to be sliced.
+    orientation : :obj:`str`
+        Orientation.
+    slice_idx : :obj:`int`
+        Slice index.
+
+    Returns
+    -------
+    :obj:`~numpy.ndarray`
+        Image slice.
+    """
+
+    axis = _get_axis(orientation)
+
+    axis_sizw = img_data.shape[axis]
+
+    if not (0 <= slice_idx < axis_sizw):
+        raise IndexError(
+            f"Slice index {slice_idx} out of bounds for axis {orientation} with size {axis_sizw}"
+        )
+
+    if axis == 2:
+        return img_data[:, :, slice_idx]
+    elif axis == 1:
+        return np.rot90(img_data[:, slice_idx, :])
+    else:
+        return np.rot90(img_data[slice_idx, :, :])
+
+
+def plot_framewise_displacement(
+    fd1: np.ndarray,
+    fd2: np.ndarray,
+    label1: str,
+    label2: str,
+    ax: Union[Axes, None] = None,
+) -> Axes:
+    """Plot frame-wise displacement data.
+
+    Plots the frame-wise displacement data corresponding to two different
+    realizations.
+
+    Parameters
+    ----------
+    fd1 : :obj:`~numpy.ndarray`
+        Frame-wise displacement values corresponding to first realization.
+    fd2 : :obj:`~numpy.ndarray`
+        Frame-wise displacement values corresponding to second realization.
+    label1 : :obj:`str`
+        Label for the first realization legend.
+    label2 : :obj:`str`
+        Label for the second realization legend.
+    ax : :obj:`Axes`, optional
+        Figure axes.
+
+    Returns
+    -------
+    ax : :obj:`Axes`
+        Figure plot axis.
+    """
+
+    fd1_dims = fd1.ndim
+    fd2_dims = fd2.ndim
+
+    n_frames_fd1 = fd1.shape
+    n_frames_fd2 = fd2.shape
+
+    req_dims = 1
+    if fd1_dims != req_dims or fd2_dims != req_dims:
+        raise ValueError(f"fd1 and fd2 must be 1D: {n_frames_fd1}; {n_frames_fd2}")
+
+    if n_frames_fd1 != n_frames_fd2:
+        raise ValueError(f"fd1 and fd2 must have same shape: {n_frames_fd1}; {n_frames_fd2}")
path: src/nifreeze/viz/motion_viz.py
user: login: jhlegarreta
body: Removed in e8fde0c. The input is now a pandas dataframe.
created_at: 2025-05-12T03:15:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,267 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+
+from typing import Union
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib.axes import Axes
+from scipy.ndimage import gaussian_filter
+
+
+def _get_axis(orientation: str):
+    """Get axis for the given orientation.
+
+    Parameters
+    ----------
+    orientation : :obj:`str`
+        Orientation. Cn be one of ``axial``, ``coronal``, or ``sagittal``.
+
+    Returns
+    -------
+    :obj:`int`
+        Axis corresponding to the given orientation.
+    """
+
+    if orientation == "axial":
+        return 2
+    elif orientation == "coronal":
+        return 1
+    elif orientation == "sagittal":
+        return 0
+    else:
+        raise ValueError(
+            f"Orientation must be one of 'axial', 'coronal', 'sagittal'. Provided: {orientation}"
+        )
+
+
+def _extract_slice(img_data: np.ndarray, orientation: str, slice_idx: int):
+    """Extract slice data from the given volume at the given orientation slice index.
+
+    Parameters
+    ----------
+    img_data : :obj:`~numpy.ndarray`
+        Image data to be sliced.
+    orientation : :obj:`str`
+        Orientation.
+    slice_idx : :obj:`int`
+        Slice index.
+
+    Returns
+    -------
+    :obj:`~numpy.ndarray`
+        Image slice.
+    """
+
+    axis = _get_axis(orientation)
+
+    axis_sizw = img_data.shape[axis]
+
+    if not (0 <= slice_idx < axis_sizw):
+        raise IndexError(
+            f"Slice index {slice_idx} out of bounds for axis {orientation} with size {axis_sizw}"
+        )
+
+    if axis == 2:
+        return img_data[:, :, slice_idx]
+    elif axis == 1:
+        return np.rot90(img_data[:, slice_idx, :])
+    else:
+        return np.rot90(img_data[slice_idx, :, :])
+
+
+def plot_framewise_displacement(
+    fd1: np.ndarray,
+    fd2: np.ndarray,
+    label1: str,
+    label2: str,
path: src/nifreeze/viz/motion_viz.py
user: login: jhlegarreta
body: Done in e8fde0c. The input is now a pandas dataframe.
created_at: 2025-05-12T03:16:22Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-04-23T02:11:03Z
body: ## [Codecov](https://app.codecov.io/gh/nipreps/nifreeze/pull/90?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nipreps) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 71.04%. Comparing base [(`6b187f8`)](https://app.codecov.io/gh/nipreps/nifreeze/commit/6b187f8c5fc4b474a881b68846551f4d8a2c5309?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nipreps) to head [(`d123760`)](https://app.codecov.io/gh/nipreps/nifreeze/commit/d1237605d71db996b497676c543ae707046070ee?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nipreps).
> Report is 3 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      nipreps/dmriprep#90      +/-   ##
==========================================
+ Coverage   70.81%   71.04%   +0.22%     
==========================================
  Files          21       22       +1     
  Lines        1028     1036       +8     
  Branches      121      121              
==========================================
+ Hits          728      736       +8     
  Misses        258      258              
  Partials       42       42              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/nipreps/nifreeze/pull/90?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nipreps).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nipreps).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: jhlegarreta
created_at: 2025-04-30T15:06:14Z
body: @mnoergaard This is something I put together  very quickly based on the visualization/plots in Spangler-Bickell, Journal of Nuclear Medicine 2022. If you find this useful for the PET notebook, as managing conflicts on notebooks is hard, and given that this may be useful beyond PET, I'd refactor this and make it into the `viz` module. WDYT?
author_association: CONTRIBUTOR
user: login: oesteban
created_at: 2025-05-01T20:32:53Z
body: Should this be against nireports?
author_association: MEMBER
user: login: jhlegarreta
created_at: 2025-05-01T20:39:13Z
body: Probably; Martin is not convinced that this is the most appropriate way to report results, for now, we can _freeze_ this, and revisit once the PET features are merged into `main`.
author_association: CONTRIBUTOR
user: login: mnoergaard
created_at: 2025-05-01T21:06:50Z
body: I think this is fine @jhlegarreta simply plotting the translations and rotations over time. Would probably also recommend to estimate the framewise_displacement and plot that as well.
author_association: CONTRIBUTOR
user: login: jhlegarreta
created_at: 2025-05-04T17:23:07Z
body: Latest push force defines functions and uses publicly available data for testing. Still WIP.

Recovering https://github.com/nipreps/nifreeze/pull/90#issuecomment-2845712413:
> Should this be against nireports?

and https://github.com/nipreps/nifreeze/pull/90#issuecomment-2845779107:
> Would probably also recommend to estimate the framewise_displacement and plot that as well.

I've found related/linked conversations and issues across other `NiPreps` repositories:
https://github.com/nipreps/dmriprep/pull/144#discussion_r561782596
https://github.com/nipreps/nireports/issues/222
https://github.com/nipreps/nireports/issues/91

And this work over here referenced in one of the comments above:
https://pmc.ncbi.nlm.nih.gov/articles/PMC6264528/pdf/main.pdf

In summary:
- Yes, these should eventually be part of `nireports`: I am doing them here so that it becomes easier to have them ready for `nifreeze`. Once a stable version of the functions is produced, we will transfer them to `nireports`.
- Something similar can be thought about the functions analyzing the motion and `mriqc`.
- Yes, the FD had been plot in Fig3 of the ISMRM abstract (`bold_realignment.ipynb`), so I'll also put that to a function.
- File and function naming is temporary for now.
- Other relevant plot functions will be added as time permits.
author_association: CONTRIBUTOR
user: login: jhlegarreta
created_at: 2025-05-10T21:14:23Z
body: ~Have tried to fix this without success:~
https://github.com/nipreps/nifreeze/actions/runs/14949160710/job/41996391916?pr=90#step:6:153

@effigies @oesteban Any suggestion? Thanks.

Edit: Solved.
author_association: CONTRIBUTOR
user: login: jhlegarreta
created_at: 2025-05-11T03:01:56Z
body: Plots:

- Volumewise motion
<img src="https://github.com/user-attachments/assets/64b5259c-5659-4b3e-9a1e-961495baf129" width="500">

- Framewise displacement
<img src="https://github.com/user-attachments/assets/17fa6697-cb3b-425d-9a79-7a3a868dbc78" width="500">

- Overlay
<img src="https://github.com/user-attachments/assets/6b569e7f-c44f-4189-a69c-f82732b9b21f" width="500">

I have other plots that we could use, but will spare those for separate PRs so that we ca move forward.

@mnoergaard hope this is useful and you can use these functions in your PR. Please suggest changes if you feel like there is something missing.
author_association: CONTRIBUTOR
user: login: jhlegarreta
created_at: 2025-05-13T04:52:40Z
body: I am merging this in order to move forward. Improvements and additional data plots can be added as we make progress.
author_association: CONTRIBUTOR