Import fMRIPrep/Tedana output into CONN toolbox

Hello fmriprep/tedana/spm12/conn people, @handwerkerd @Steven

We have multi-echo fmri data that’s been processed with fmriprep (motion/slice timing corrected), then put through tedana to denoise it. The data remains in native space.

We now want to put this through the CONN toolbox for estimating a gPPI model. However, when run the “Default Preprocessing Pipeline volume based analyses (direct normalization to MNI space)” we are encountering misregistration. FYI we deselect ART and STC, but left everything else in there (so effectively motion correcting twice). It also fails when omitting functional realignment i.e. “subject motion estimation and correction”.

When looking at the output we find that the structural image has been well registered to the MNI, but the functional is way off. See below.

Under Functional Data Tools in CONN, we select ‘Display coregistration with anatomical data’ and get the following:

Is this a failure of setting the origin, something else? Really stuck with this.

If anyone has a suggestion for how to take our native-space fmriprep/tedana output into MNI space using CONN I’d be most grateful to hear it.

Best wishes,

Jon&Eszter

Hi @Jon_Brooks,

What if you warp to MNI before conn? And why motion correct twice?

Best,

Steven

Hi @Steven

Thanks for getting back to us. This is partly historical choice: we felt that running tedana in native space was preferable (I think this is the recommendation) - we then processed the output in FSL for simple group analysis. FSL does the registration for us.

I think that the standard CONN pre-processing pipeline is designed to take native space fMRI data and transform it to MNI space for further analysis. I’d like to take advantage of its defined pipeline if possible.

You’re right we didn’t mean to realign (motion correct) twice - but it doesn’t run without it :smiley: and doesn’t work with it either :upside_down_face:

This seems like it should be an easy problem to solve, but we’re banging our heads against a brick wall here…

Cheers, Jon

Hi @Jon_Brooks,

I am not sure what registration parameters CONN runs under the hood, but typical fMRIPrep import options in CONN uses the MNI directly, so you should be able to import already-standardized images to CONN and disable the registration in CONN.

Best,

Steven

Hi @Jon_Brooks ,

You could also just use Nilearn to code a gPPI (of course, will need you to apply the fMRIPrep-generated MNI xfms to your images first). Here is an example created by AI:

import numpy as np
import pandas as pd
from scipy.linalg import toeplitz

from nilearn.glm.first_level import (
    FirstLevelModel,
    compute_regressor,
    make_first_level_design_matrix,
)
from nilearn.glm.first_level.hemodynamic_models import glover_hrf
from nilearn.maskers import NiftiSpheresMasker


# ------------------------------------------------------------
# 1. Inputs
# ------------------------------------------------------------

func_img = "sub-01_task-example_bold.nii.gz"
events = pd.read_csv("sub-01_task-example_events.tsv", sep="\t")
confounds = pd.read_csv("sub-01_task-example_confounds.tsv", sep="\t")

tr = 2.0
n_scans = 240
frame_times = np.arange(n_scans) * tr

seed_xyz = [(0, -52, 26)]  # example PCC coordinate
condition_names = ["condition_A", "condition_B", "condition_C"]


# ------------------------------------------------------------
# 2. Extract the physiological seed signal
# ------------------------------------------------------------

seed_masker = NiftiSpheresMasker(
    seed_xyz,
    radius=6,
    detrend=False,
    standardize=False,
    t_r=tr,
)

seed_bold = seed_masker.fit_transform(
    func_img,
    confounds=confounds,
).squeeze()

# Centering is important for interpretability and collinearity.
seed_bold = seed_bold - seed_bold.mean()


# ------------------------------------------------------------
# 3. Approximate HRF deconvolution
# ------------------------------------------------------------

def ridge_deconvolve(signal, tr, alpha=1.0):
    """Approximate neural signal using regularized HRF deconvolution."""
    n = len(signal)

    hrf = glover_hrf(
        t_r=tr,
        oversampling=1,
        time_length=32.0,
        onset=0.0,
    )
    hrf = hrf[:n]

    # Convolution matrix H such that BOLD ≈ H @ neural_signal.
    first_col = np.r_[hrf[0], np.zeros(n - 1)]
    first_row = np.r_[hrf, np.zeros(max(0, n - len(hrf)))]
    H = toeplitz(first_col, first_row[:n])

    # Ridge-regularized inverse.
    lhs = H.T @ H + alpha * np.eye(n)
    rhs = H.T @ signal

    return np.linalg.solve(lhs, rhs)


seed_neural = ridge_deconvolve(seed_bold, tr=tr, alpha=1.0)
seed_neural -= seed_neural.mean()


# ------------------------------------------------------------
# 4. Construct one neural psychological vector per condition
# ------------------------------------------------------------

def condition_boxcar(events, condition, frame_times):
    """
    Create a scan-resolution, unconvolved psychological vector.

    For sub-TR event timing, construct this on an oversampled time grid
    instead of directly at scan resolution.
    """
    vector = np.zeros(len(frame_times), dtype=float)

    selected = events.loc[events["trial_type"] == condition]

    for onset, duration in zip(selected["onset"], selected["duration"]):
        active = (
            (frame_times >= onset)
            & (frame_times < onset + duration)
        )
        vector[active] = 1.0

    # Mean-centering is standard for the interaction.
    return vector - vector.mean()


def convolve_interaction(interaction_neural, frame_times, name):
    """
    Convolve a scan-resolution neural interaction using Nilearn's HRF.
    """
    onsets = frame_times
    durations = np.zeros_like(frame_times)
    amplitudes = interaction_neural

    experimental_condition = np.vstack(
        [onsets, durations, amplitudes]
    )

    regressor, _ = compute_regressor(
        experimental_condition,
        hrf_model="glover",
        frame_times=frame_times,
        con_id=name,
        oversampling=1,
    )

    return regressor[:, 0]


ppi_regressors = {}

for condition in condition_names:
    psych_neural = condition_boxcar(
        events,
        condition,
        frame_times,
    )

    interaction_neural = psych_neural * seed_neural

    ppi_regressors[f"ppi_{condition}"] = convolve_interaction(
        interaction_neural,
        frame_times,
        name=f"ppi_{condition}",
    )


# ------------------------------------------------------------
# 5. Build the full first-level design matrix
# ------------------------------------------------------------

# Select suitable nuisance variables for your dataset.
confound_columns = [
    "trans_x", "trans_y", "trans_z",
    "rot_x", "rot_y", "rot_z",
]

nuisance = (
    confounds[confound_columns]
    .fillna(0)
    .to_numpy()
)

extra_regressors = np.column_stack(
    [
        seed_bold,
        *[ppi_regressors[f"ppi_{c}"] for c in condition_names],
        nuisance,
    ]
)

extra_names = (
    ["seed"]
    + [f"ppi_{c}" for c in condition_names]
    + confound_columns
)

design_matrix = make_first_level_design_matrix(
    frame_times=frame_times,
    events=events,
    hrf_model="glover",
    drift_model="cosine",
    high_pass=0.01,
    add_regs=extra_regressors,
    add_reg_names=extra_names,
)


# ------------------------------------------------------------
# 6. Fit the model
# ------------------------------------------------------------

model = FirstLevelModel(
    t_r=tr,
    noise_model="ar1",
    standardize=False,
    signal_scaling=0,
    minimize_memory=False,
)

model = model.fit(
    func_img,
    design_matrices=design_matrix,
)


# ------------------------------------------------------------
# 7. Compute gPPI contrasts
# ------------------------------------------------------------

# Condition-specific PPI:
ppi_A_map = model.compute_contrast(
    "ppi_condition_A",
    output_type="z_score",
)

# Differential connectivity: A > B
ppi_A_gt_B_map = model.compute_contrast(
    "ppi_condition_A - ppi_condition_B",
    output_type="z_score",
)