Skip to content

Preprocessing

The preprocessing pipeline converts raw imaging data into simulation-ready head meshes. This is a one-time setup per subject — once complete, you can run unlimited simulations without repeating these steps.

graph LR
    A([DICOM]) -->|dcm2niix| B([NIfTI T1/T2])
    B -->|CHARM| C([Head Mesh])
    C -->|subject_atlas| D([Atlas Parcellations])
    B -->|FastSurfer seg_only| E([DKT Segmentation])
    C -->|tissue analysis| F([Tissue Report])
    B -->|QSIPrep| G([DWI Preprocessed])
    G -->|DIPY fit + chain| H([DTI Tensor])
    C --> H
    style A fill:#1a3a5c,stroke:#48a,color:#fff
    style B fill:#1a5c4a,stroke:#4a8,color:#fff
    style C fill:#1a5c4a,stroke:#4a8,color:#fff
    style D fill:#1a5c4a,stroke:#4a8,color:#fff
    style E fill:#1a5c4a,stroke:#4a8,color:#fff
    style F fill:#1a5c4a,stroke:#4a8,color:#fff
    style G fill:#1a5c4a,stroke:#4a8,color:#fff
    style H fill:#1a5c4a,stroke:#4a8,color:#fff

Full Pipeline

Run all preprocessing steps with a single call:

from tit import get_path_manager
from tit.pre import run_pipeline

get_path_manager("/path/to/bids_project")
exit_code = run_pipeline(
    subject_ids=["001", "002"],
    convert_dicom=True,
    run_fastsurfer=True,
    fastsurfer_threads=4,
    create_m2m=True,
    run_tissue_analysis=True,
    run_qsiprep=False,
    run_qsirecon=False,
    extract_dti=False,
)

Selective Steps

Each boolean flag controls a specific step. For example, enable only create_m2m=True for CHARM and its automatic subject_atlas step, or only run_fastsurfer=True for DKT deep segmentation. FastSurfer uses --seg_only. Enable run_freesurfer=True for optional recon-all and freesurfer_subregions=["thalamus", "hippo-amygdala"] for T1 subregions. Subregions alone require a completed FreeSurfer reconstruction.

Individual Steps

Each preprocessing step can be called independently for finer control:

from tit.pre import (
    run_dicom_to_nifti,
    run_fastsurfer,
    run_charm,
    run_tissue_analysis,
    run_qsiprep,
    run_qsirecon,
    extract_dti_tensor,
    discover_subjects,
    check_m2m_exists,
)

# Discover subjects from a BIDS project
import logging

project = "/path/to/bids_project"
logger = logging.getLogger("preprocessing")
subjects = discover_subjects(project)

# Check if head mesh already exists
if not check_m2m_exists(project, "001"):
    run_charm(project, "001", logger=logger)

Step Details

Step Function What It Does
DICOM to NIfTI run_dicom_to_nifti() Converts DICOM files to NIfTI format using dcm2niix
CHARM head mesh run_charm() Creates SimNIBS-compatible head mesh from T1/T2 images
Subject atlas run_subject_atlas() Creates atlas-based parcellations (a2009s, DK40, HCP_MMP1); runs automatically after CHARM in the pipeline
FastSurfer segmentation run_fastsurfer() Optional DKT deep segmentation from the raw BIDS T1w image (--seg_only)
FreeSurfer reconstruction / subregions run_freesurfer() Optional recon-all, thalamic nuclei and hippocampal/amygdala subregions; requires a license and at least 16 GiB available container memory
Tissue analysis run_tissue_analysis() Analyzes tissue thickness and volume (bone, CSF, skin) from the head mesh

Resources

fastsurfer_threads controls the FastSurfer CPU thread count. The preprocessing pipeline processes subjects sequentially; the removed parallel_recon and parallel_cores arguments are not accepted. Runtime depends on the host and input.

DTI / Diffusion Pipeline

For anisotropic conductivity simulations, QSIPrep (a sibling Docker container) preprocesses the DWI and extract_dti_tensor fits the tensor with DIPY (WLS, b ≤ 1500), maps it onto the m2m T1 grid with QSIPrep's exact ACPC → T1 transforms, and writes DTI_coregT1_tensor.nii.gz plus DTI_coregT1_qc.json only if the QC gate passes. QSIRecon is optional (tractography, scalar maps, connectivity) and not needed for the tensor.

from tit.pre import run_qsiprep, extract_dti_tensor
import logging

logger = logging.getLogger("my_pipeline")
project = "/path/to/bids_project"

# QSIPrep: distortion correction, unringing and output resolution are chosen from the data
run_qsiprep(project, "001", logger=logger)

# DTI tensor for SimNIBS anisotropic conductivity (needs QSIPrep output and charm)
extract_dti_tensor(project, "001", logger=logger)

In the full pipeline set run_qsiprep=True and extract_dti=True (run_qsirecon=True only for QSIRecon's own outputs). qsiprep_config defaults: output_resolution=None (native DWI voxel size), unringing_method="auto" (rpg for partial-Fourier data, else mrdegibbs), mni_normalization=False (enable only for QSIRecon atlases).

Platform

QSIPrep needs an x86-64 (Linux or Windows) Docker host. On Apple Silicon it fails at SynthSeg (TensorFlow needs AVX, which emulation lacks) and run_qsiprep refuses to start. Run QSIPrep elsewhere and copy derivatives/qsiprep/sub-<id>/ into the project; extract_dti_tensor runs on any host.

BIDS Directory Structure

After preprocessing, your project follows this layout:

project_root/
├── sourcedata/              # Raw DICOM
├── sub-001/
│   └── anat/               # NIfTI files (T1w, T2w)
└── derivatives/
    ├── SimNIBS/sub-001/
    │   └── m2m_001/         # Head mesh (simulation-ready)
    │       └── segmentation/ # Atlas parcellations
    ├── fastsurfer/sub-001/  # optional DKT deep segmentation
    ├── qsiprep/sub-001/     # QSIPrep DWI outputs (if run)
    └── qsirecon/sub-001/    # optional QSIRecon outputs (if run)

API Reference

tit.pre.structural.run_pipeline

run_pipeline(subject_ids: Iterable[str], *, convert_dicom: bool = False, run_fastsurfer: bool = False, charm_threads: int | None = None, charm_options: dict | None = None, fastsurfer_threads: int | None = None, run_freesurfer: bool = False, freesurfer_recon_all: bool = True, freesurfer_subregions: list[str] | None = None, freesurfer_threads: int | None = None, create_m2m: bool = False, run_tissue_analysis: bool = False, run_qsiprep: bool = False, run_qsirecon: bool = False, qsiprep_config: dict | None = None, qsi_recon_config: dict | None = None, extract_dti: bool = False, skip_existing_outputs: bool = False, replace_existing_outputs: bool = False, stop_event: object | None = None, logger_callback: Callable | None = None, runner: CommandRunner | None = None) -> int

Run the preprocessing pipeline for one or more subjects.

Orchestrates DICOM conversion, SimNIBS CHARM, FastSurfer deep segmentation, tissue analysis, QSIPrep/QSIRecon DWI preprocessing and DTI tensor extraction. Steps are enabled via boolean flags; disabled steps are skipped.

All step flags default to False; pass keyword arguments only.

Parameters

subject_ids : iterable of str Subject identifiers without the sub- prefix (e.g. ["ernie", "101"]). Subjects run sequentially. convert_dicom : bool, optional Run DICOM-to-NIfTI conversion (sourcedata/sub-<id>/ to sub-<id>/anat/). run_fastsurfer : bool, optional Run FastSurfer --seg_only deep segmentation. charm_threads : int or None, optional Thread count for SimNIBS charm; None uses its default. charm_options : dict or None, optional charm overrides: {"denoise": bool, "segmentation_final_resolution": 0.5-2.0, "skin_facet_size": 0.5-10.0} (keys optional; unknown keys raise ValueError). None keeps the installed defaults. fastsurfer_threads : int or None, optional Thread count for FastSurfer inference. run_freesurfer : bool, optional Run FreeSurfer (requires a FreeSurfer install, which the standard image does not ship). freesurfer_recon_all : bool, optional With run_freesurfer, run recon-all (default True); set False to run only freesurfer_subregions on an existing reconstruction. freesurfer_subregions : list of str or None, optional FreeSurfer subregion segmentations to add: any of "thalamus", "hippo-amygdala". freesurfer_threads : int or None, optional Thread count for FreeSurfer. create_m2m : bool, optional Run SimNIBS charm to build m2m_<id> (also runs subject_atlas for .annot files). run_tissue_analysis : bool, optional Run tissue-volume and thickness analysis. run_qsiprep : bool, optional Run QSIPrep DWI preprocessing via Docker. run_qsirecon : bool, optional Run QSIRecon reconstruction via Docker. qsiprep_config : dict or None, optional Extra configuration passed to run_qsiprep. qsi_recon_config : dict or None, optional Extra configuration passed to run_qsirecon. extract_dti : bool, optional Extract DTI tensor for SimNIBS anisotropic conductivity. skip_existing_outputs : bool, optional Skip selected preprocessing steps when their output already exists. replace_existing_outputs : bool, optional Remove selected existing outputs before rerunning their steps. stop_event : object or None, optional Threading event used to cancel running steps. logger_callback : callable or None, optional Callback used by the GUI to capture log lines. runner : CommandRunner or None, optional Subprocess runner used to stream command output.

Returns

int 0 on success, 1 on failure.

Raises

PreprocessError If no subjects are provided, both skip_existing_outputs and replace_existing_outputs are set, a required input is missing for a selected step (checked for every subject before anything runs), or a preprocessing step fails. PreprocessCancelled If stop_event is set during execution.

Examples

from tit.pre import run_pipeline run_pipeline(["ernie"], convert_dicom=True, create_m2m=True) # doctest: +SKIP 0 run_pipeline(["ernie", "101"], create_m2m=True, ... charm_options={"denoise": True}, ... skip_existing_outputs=True) # doctest: +SKIP 0

See Also

run_dicom_to_nifti : DICOM-to-NIfTI conversion step. run_fastsurfer : FastSurfer deep-segmentation step. run_charm : SimNIBS CHARM head-mesh step. run_tissue_analysis : Tissue analysis step. run_qsiprep : QSIPrep DWI preprocessing step. run_qsirecon : QSIRecon reconstruction step. extract_dti_tensor : DTI tensor extraction step.

Source code in tit/pre/structural.py
def run_pipeline(
    subject_ids: Iterable[str],
    *,
    convert_dicom: bool = False,
    run_fastsurfer: bool = False,
    charm_threads: int | None = None,
    charm_options: dict | None = None,
    fastsurfer_threads: int | None = None,
    run_freesurfer: bool = False,
    freesurfer_recon_all: bool = True,
    freesurfer_subregions: list[str] | None = None,
    freesurfer_threads: int | None = None,
    create_m2m: bool = False,
    run_tissue_analysis: bool = False,
    run_qsiprep: bool = False,
    run_qsirecon: bool = False,
    qsiprep_config: dict | None = None,
    qsi_recon_config: dict | None = None,
    extract_dti: bool = False,
    skip_existing_outputs: bool = False,
    replace_existing_outputs: bool = False,
    stop_event: object | None = None,
    logger_callback: Callable | None = None,
    runner: CommandRunner | None = None,
) -> int:
    """Run the preprocessing pipeline for one or more subjects.

    Orchestrates DICOM conversion, SimNIBS CHARM, FastSurfer deep
    segmentation, tissue analysis, QSIPrep/QSIRecon DWI preprocessing and
    DTI tensor extraction.  Steps are enabled via boolean flags; disabled
    steps are skipped.

    All step flags default to ``False``; pass keyword arguments only.

    Parameters
    ----------
    subject_ids : iterable of str
        Subject identifiers without the ``sub-`` prefix (e.g.
        ``["ernie", "101"]``).  Subjects run sequentially.
    convert_dicom : bool, optional
        Run DICOM-to-NIfTI conversion (``sourcedata/sub-<id>/`` to
        ``sub-<id>/anat/``).
    run_fastsurfer : bool, optional
        Run FastSurfer ``--seg_only`` deep segmentation.
    charm_threads : int or None, optional
        Thread count for SimNIBS ``charm``; ``None`` uses its default.
    charm_options : dict or None, optional
        ``charm`` overrides: ``{"denoise": bool,
        "segmentation_final_resolution": 0.5-2.0,
        "skin_facet_size": 0.5-10.0}`` (keys optional; unknown keys
        raise ``ValueError``).  ``None`` keeps the installed defaults.
    fastsurfer_threads : int or None, optional
        Thread count for FastSurfer inference.
    run_freesurfer : bool, optional
        Run FreeSurfer (requires a FreeSurfer install, which the standard
        image does not ship).
    freesurfer_recon_all : bool, optional
        With *run_freesurfer*, run ``recon-all`` (default ``True``); set
        ``False`` to run only *freesurfer_subregions* on an existing
        reconstruction.
    freesurfer_subregions : list of str or None, optional
        FreeSurfer subregion segmentations to add: any of
        ``"thalamus"``, ``"hippo-amygdala"``.
    freesurfer_threads : int or None, optional
        Thread count for FreeSurfer.
    create_m2m : bool, optional
        Run SimNIBS ``charm`` to build ``m2m_<id>`` (also runs
        ``subject_atlas`` for ``.annot`` files).
    run_tissue_analysis : bool, optional
        Run tissue-volume and thickness analysis.
    run_qsiprep : bool, optional
        Run QSIPrep DWI preprocessing via Docker.
    run_qsirecon : bool, optional
        Run QSIRecon reconstruction via Docker.
    qsiprep_config : dict or None, optional
        Extra configuration passed to ``run_qsiprep``.
    qsi_recon_config : dict or None, optional
        Extra configuration passed to ``run_qsirecon``.
    extract_dti : bool, optional
        Extract DTI tensor for SimNIBS anisotropic conductivity.
    skip_existing_outputs : bool, optional
        Skip selected preprocessing steps when their output already exists.
    replace_existing_outputs : bool, optional
        Remove selected existing outputs before rerunning their steps.
    stop_event : object or None, optional
        Threading event used to cancel running steps.
    logger_callback : callable or None, optional
        Callback used by the GUI to capture log lines.
    runner : CommandRunner or None, optional
        Subprocess runner used to stream command output.

    Returns
    -------
    int
        ``0`` on success, ``1`` on failure.

    Raises
    ------
    PreprocessError
        If no subjects are provided, both *skip_existing_outputs* and
        *replace_existing_outputs* are set, a required input is missing
        for a selected step (checked for every subject before anything
        runs), or a preprocessing step fails.
    PreprocessCancelled
        If *stop_event* is set during execution.

    Examples
    --------
    >>> from tit.pre import run_pipeline
    >>> run_pipeline(["ernie"], convert_dicom=True, create_m2m=True)  # doctest: +SKIP
    0
    >>> run_pipeline(["ernie", "101"], create_m2m=True,
    ...              charm_options={"denoise": True},
    ...              skip_existing_outputs=True)  # doctest: +SKIP
    0

    See Also
    --------
    run_dicom_to_nifti : DICOM-to-NIfTI conversion step.
    run_fastsurfer : FastSurfer deep-segmentation step.
    run_charm : SimNIBS CHARM head-mesh step.
    run_tissue_analysis : Tissue analysis step.
    run_qsiprep : QSIPrep DWI preprocessing step.
    run_qsirecon : QSIRecon reconstruction step.
    extract_dti_tensor : DTI tensor extraction step.
    """
    from tit.telemetry import track_operation
    from tit import constants as _const

    subject_list = [str(s).strip() for s in subject_ids if str(s).strip()]
    if not subject_list:
        raise PreprocessError("No subjects provided.")
    if skip_existing_outputs and replace_existing_outputs:
        raise PreprocessError(
            "skip_existing_outputs and replace_existing_outputs cannot both be true."
        )

    pm = get_path_manager()
    project_dir = pm._root()
    input_problems = find_missing_preprocessing_inputs(
        project_dir,
        subject_list,
        convert_dicom=convert_dicom,
        create_m2m=create_m2m,
        run_fastsurfer=run_fastsurfer,
        run_freesurfer=run_freesurfer,
        freesurfer_recon_all=freesurfer_recon_all,
        freesurfer_subregions=freesurfer_subregions or [],
        run_qsiprep=run_qsiprep,
        run_qsirecon=run_qsirecon,
        extract_dti=extract_dti,
        skip_existing_outputs=skip_existing_outputs,
    )
    if input_problems:
        raise PreprocessError(_format_input_problems(input_problems))

    with track_operation(_const.TELEMETRY_OP_PRE_PIPELINE):
        return _run_pipeline_inner(
            subject_list,
            convert_dicom=convert_dicom,
            run_fastsurfer=run_fastsurfer,
            charm_threads=charm_threads,
            charm_options=charm_options,
            fastsurfer_threads=fastsurfer_threads,
            run_freesurfer=run_freesurfer,
            freesurfer_recon_all=freesurfer_recon_all,
            freesurfer_subregions=freesurfer_subregions,
            freesurfer_threads=freesurfer_threads,
            create_m2m=create_m2m,
            run_tissue_analysis=run_tissue_analysis,
            run_qsiprep=run_qsiprep,
            run_qsirecon=run_qsirecon,
            qsiprep_config=qsiprep_config,
            qsi_recon_config=qsi_recon_config,
            extract_dti=extract_dti,
            skip_existing_outputs=skip_existing_outputs,
            replace_existing_outputs=replace_existing_outputs,
            stop_event=stop_event,
            logger_callback=logger_callback,
            runner=runner,
        )

tit.pre.utils.discover_subjects

discover_subjects(project_dir: str | None) -> list[str]

Return sorted, deduplicated subject IDs found in a BIDS project tree.

Returns an empty list when project_dir is None (project not configured).

Discovery order:

  1. sourcedata/sub-*/T1w/ or T2w/ -- any subdir, NIfTI, DICOM, or supported DICOM archive (.zip, .tar, .tar.gz, .tgz).
  2. sourcedata/sub-*/*.tgz (compressed bundles at top level).
  3. sub-*/anat/*T1w*.nii[.gz] or *T2w*.nii[.gz] at project root.

Parameters

project_dir : str BIDS project root directory.

Returns

list[str] Sorted list of subject identifiers (without the sub- prefix).

See Also

check_m2m_exists : Check whether a subject's m2m directory exists.

Source code in tit/pre/utils.py
def discover_subjects(project_dir: str | None) -> list[str]:
    """Return sorted, deduplicated subject IDs found in a BIDS project tree.

    Returns an empty list when *project_dir* is ``None`` (project not
    configured).

    Discovery order:

    1. ``sourcedata/sub-*/T1w/`` or ``T2w/`` -- any subdir, NIfTI, DICOM,
       or supported DICOM archive (``.zip``, ``.tar``, ``.tar.gz``, ``.tgz``).
    2. ``sourcedata/sub-*/*.tgz`` (compressed bundles at top level).
    3. ``sub-*/anat/*T1w*.nii[.gz]`` or ``*T2w*.nii[.gz]`` at project root.

    Parameters
    ----------
    project_dir : str
        BIDS project root directory.

    Returns
    -------
    list[str]
        Sorted list of subject identifiers (without the ``sub-`` prefix).

    See Also
    --------
    check_m2m_exists : Check whether a subject's m2m directory exists.
    """
    if project_dir is None:
        return []

    found: list[str] = []

    sourcedata_dir = os.path.join(project_dir, "sourcedata")
    if os.path.exists(sourcedata_dir):
        for subj_dir in glob.glob(os.path.join(sourcedata_dir, "sub-*")):
            if os.path.isdir(subj_dir):
                t1w_dir = os.path.join(subj_dir, "T1w")
                t2w_dir = os.path.join(subj_dir, "T2w")

                supported_modality_files = (
                    ".dcm",
                    ".dicom",
                    ".zip",
                    ".tar",
                    ".tar.gz",
                    ".tgz",
                    ".json",
                    ".nii",
                    ".nii.gz",
                )
                has_valid_structure = (
                    (
                        os.path.exists(t1w_dir)
                        and (
                            any(
                                os.path.isdir(os.path.join(t1w_dir, d))
                                for d in os.listdir(t1w_dir)
                            )
                            or any(
                                f.lower().endswith(supported_modality_files)
                                for f in os.listdir(t1w_dir)
                            )
                        )
                    )
                    or (
                        os.path.exists(t2w_dir)
                        and (
                            any(
                                os.path.isdir(os.path.join(t2w_dir, d))
                                for d in os.listdir(t2w_dir)
                            )
                            or any(
                                f.lower().endswith(supported_modality_files)
                                for f in os.listdir(t2w_dir)
                            )
                        )
                    )
                    or any(f.endswith(".tgz") for f in os.listdir(subj_dir))
                )

                if has_valid_structure:
                    subject_id = os.path.basename(subj_dir).replace("sub-", "")
                    found.append(subject_id)

    for subj_dir in glob.glob(os.path.join(project_dir, "sub-*")):
        if os.path.isdir(subj_dir):
            subject_id = os.path.basename(subj_dir).replace("sub-", "")
            if subject_id in found:
                continue
            anat_dir = os.path.join(subj_dir, "anat")
            if os.path.exists(anat_dir):
                has_nifti = any(
                    f.endswith((".nii", ".nii.gz")) and ("T1w" in f or "T2w" in f)
                    for f in os.listdir(anat_dir)
                )
                if has_nifti:
                    found.append(subject_id)

    return sorted(found)

tit.pre.utils.check_m2m_exists

check_m2m_exists(project_dir: str, subject_id: str) -> bool

Return True if the SimNIBS m2m directory already exists.

Checks for <project_dir>/derivatives/SimNIBS/sub-<subject_id>/m2m_<subject_id>.

Parameters

project_dir : str BIDS project root directory. subject_id : str Subject identifier without the sub- prefix.

Returns

bool True if the m2m directory exists on disk.

See Also

discover_subjects : Find all subject IDs in a BIDS project. run_charm : Generate the m2m head mesh.

Source code in tit/pre/utils.py
def check_m2m_exists(project_dir: str, subject_id: str) -> bool:
    """Return ``True`` if the SimNIBS m2m directory already exists.

    Checks for
    ``<project_dir>/derivatives/SimNIBS/sub-<subject_id>/m2m_<subject_id>``.

    Parameters
    ----------
    project_dir : str
        BIDS project root directory.
    subject_id : str
        Subject identifier without the ``sub-`` prefix.

    Returns
    -------
    bool
        ``True`` if the m2m directory exists on disk.

    See Also
    --------
    discover_subjects : Find all subject IDs in a BIDS project.
    run_charm : Generate the m2m head mesh.
    """
    m2m_dir = os.path.join(
        project_dir,
        "derivatives",
        "SimNIBS",
        f"sub-{subject_id}",
        f"m2m_{subject_id}",
    )
    return os.path.exists(m2m_dir)

tit.pre.dicom2nifti.run_dicom_to_nifti

run_dicom_to_nifti(project_dir: str, subject_id: str, *, logger, runner: CommandRunner | None = None) -> None

Ingest a subject's source images into BIDS-named NIfTI files.

Looks for a T1w, T2w, ct, and dwi folder under sourcedata/sub-{subject_id}/ (folder names are matched case-insensitively) and ingests each one that exists. Anatomical images and CT go to the subject's anat/ folder, diffusion images to dwi/ (with their .bval/.bvec sidecars).

Each modality folder is searched recursively. Supported archives (.zip, .tar, .tar.gz, .tgz) are safely extracted to extracted_archives/ first. DICOM files (.dcm/.dicom) are then converted with dcm2niix; if the folder holds no DICOMs but does hold a NIfTI, that file is copied into place instead (compressing a bare .nii on the way). Dotfiles are ignored throughout.

CT is written as anat/sub-{id}_ct.nii.gz. This is a local convention, not BIDS -- see :data:MODALITIES.

Parameters

project_dir : str BIDS project root directory. subject_id : str Subject identifier without the sub- prefix. logger : logging.Logger Logger for progress messages. runner : CommandRunner or None, optional Subprocess runner for streaming output.

Raises

PreprocessError If an output NIfTI already exists for a modality.

See Also

run_pipeline : Full preprocessing pipeline. MODALITIES : Supported modalities and their BIDS datatype directories.

Source code in tit/pre/dicom2nifti.py
def run_dicom_to_nifti(
    project_dir: str,
    subject_id: str,
    *,
    logger,
    runner: CommandRunner | None = None,
) -> None:
    """Ingest a subject's source images into BIDS-named NIfTI files.

    Looks for a ``T1w``, ``T2w``, ``ct``, and ``dwi`` folder under
    ``sourcedata/sub-{subject_id}/`` (folder names are matched
    case-insensitively) and ingests each one that exists. Anatomical images
    and CT go to the subject's ``anat/`` folder, diffusion images to ``dwi/``
    (with their ``.bval``/``.bvec`` sidecars).

    Each modality folder is searched recursively. Supported archives
    (``.zip``, ``.tar``, ``.tar.gz``, ``.tgz``) are safely extracted to
    ``extracted_archives/`` first. DICOM files (``.dcm``/``.dicom``) are then
    converted with ``dcm2niix``; if the folder holds no DICOMs but does hold a
    NIfTI, that file is copied into place instead (compressing a bare ``.nii``
    on the way). Dotfiles are ignored throughout.

    CT is written as ``anat/sub-{id}_ct.nii.gz``. This is a local convention,
    not BIDS -- see :data:`MODALITIES`.

    Parameters
    ----------
    project_dir : str
        BIDS project root directory.
    subject_id : str
        Subject identifier without the ``sub-`` prefix.
    logger : logging.Logger
        Logger for progress messages.
    runner : CommandRunner or None, optional
        Subprocess runner for streaming output.

    Raises
    ------
    PreprocessError
        If an output NIfTI already exists for a modality.

    See Also
    --------
    run_pipeline : Full preprocessing pipeline.
    MODALITIES : Supported modalities and their BIDS datatype directories.
    """
    from tit.telemetry import track_operation
    from tit import constants as _const

    with track_operation(_const.TELEMETRY_OP_PRE_DICOM):
        pm = get_path_manager(project_dir)
        sourcedata_dir = Path(pm.sourcedata_subject(subject_id))

        converted = False
        for modality, datatype in MODALITIES:
            modality_dir = _modality_source_dir(sourcedata_dir, modality)
            if not modality_dir.exists():
                continue
            output_dir = Path(pm.bids_datatype(subject_id, datatype))
            if _ingest_modality(
                modality_dir, output_dir, subject_id, modality, logger, runner
            ):
                converted = True

        if not converted:
            logger.warning("No DICOM or NIfTI files found or converted")

tit.pre.fastsurfer.run_fastsurfer

run_fastsurfer(project_dir: str, subject_id: str, *, logger, runner: CommandRunner | None = None, threads: int | None = None) -> None

Run FastSurfer --seg_only for one subject.

Writes derivatives/fastsurfer/sub-<id>/mri/aparc.DKTatlas+aseg.deep.mgz plus a .nii.gz copy and a *_labels.txt sidecar, then returns. Idempotent: an existing segmentation is left alone (only the derived NIfTI/labels are backfilled if missing).

The input is the raw BIDS T1w, the same image recon-all was given. Two reasons, both load-bearing: it keeps this stage independent of charm so the two can run in parallel after DICOM import (the DAG in :mod:tit.jobs.plans relies on that), and it is the image the spike's Dice numbers were measured on -- m2m_<id>/T1.nii.gz is charm's own bias-corrected, re-conformed volume, a different input with unmeasured effect on the network's output.

Parameters

project_dir : str BIDS project root. subject_id : str Subject identifier without the sub- prefix. logger : logging.Logger Logger used for progress and streamed command output. runner : CommandRunner or None, optional Subprocess runner used to stream output and honour cancellation. threads : int or None, optional Thread count for the inference. Defaults to $TIT_FASTSURFER_THREADS, else the user-wide preference (automatic: the global CPU limit).

Raises

PreprocessError If FastSurfer is not installed, no T1w is found, run_fastsurfer.sh exits non-zero, or the expected segmentation is missing afterwards.

See Also

fastsurfer_available : Probe before offering this step in a UI. tit.pre.charm.run_charm : The other post-import stage (G2a).

Source code in tit/pre/fastsurfer.py
def run_fastsurfer(
    project_dir: str,
    subject_id: str,
    *,
    logger,
    runner: CommandRunner | None = None,
    threads: int | None = None,
) -> None:
    """Run FastSurfer ``--seg_only`` for one subject.

    Writes ``derivatives/fastsurfer/sub-<id>/mri/aparc.DKTatlas+aseg.deep.mgz``
    plus a ``.nii.gz`` copy and a ``*_labels.txt`` sidecar, then returns.
    Idempotent: an existing segmentation is left alone (only the derived
    NIfTI/labels are backfilled if missing).

    The input is the **raw BIDS T1w**, the same image ``recon-all`` was
    given. Two reasons, both load-bearing: it keeps this stage independent
    of ``charm`` so the two can run in parallel after DICOM import (the DAG
    in :mod:`tit.jobs.plans` relies on that), and it is the image the
    spike's Dice numbers were measured on -- ``m2m_<id>/T1.nii.gz`` is
    charm's own bias-corrected, re-conformed volume, a different input with
    unmeasured effect on the network's output.

    Parameters
    ----------
    project_dir : str
        BIDS project root.
    subject_id : str
        Subject identifier without the ``sub-`` prefix.
    logger : logging.Logger
        Logger used for progress and streamed command output.
    runner : CommandRunner or None, optional
        Subprocess runner used to stream output and honour cancellation.
    threads : int or None, optional
        Thread count for the inference. Defaults to
        ``$TIT_FASTSURFER_THREADS``, else the user-wide preference (automatic: the global CPU limit).

    Raises
    ------
    PreprocessError
        If FastSurfer is not installed, no T1w is found, ``run_fastsurfer.sh``
        exits non-zero, or the expected segmentation is missing afterwards.

    See Also
    --------
    fastsurfer_available : Probe before offering this step in a UI.
    tit.pre.charm.run_charm : The other post-import stage (G2a).
    """
    from tit.telemetry import track_operation
    from tit import constants as _const

    with track_operation(_const.TELEMETRY_OP_PRE_FASTSURFER):
        pm = get_path_manager(project_dir)

        subject_dir = Path(pm.fastsurfer_subject(subject_id))
        mri_dir = Path(pm.fastsurfer_mri(subject_id))
        seg_path = mri_dir / SEG_FILENAME

        if seg_path.is_file():
            logger.info(
                f"FastSurfer segmentation already exists at {seg_path}; skipping."
            )
            write_derived_outputs(mri_dir, logger=logger)
            return

        t1_file, _t2_file = _find_anat_files(subject_id)
        if not t1_file:
            bids_anat_dir = Path(pm.bids_anat(subject_id))
            raise PreprocessError(f"No T1 file found in {bids_anat_dir}")

        subjects_root = subject_dir.parent
        subjects_root.mkdir(parents=True, exist_ok=True)

        n_threads = resolve_threads(threads)
        if runner is None:
            runner = CommandRunner()
        requested_device = resolve_device()
        env = inference_environment()
        device, reason = (
            ("cpu", "Native Metal explicitly requested")
            if requested_device == "mps"
            else _probe_device(requested_device, env)
        )
        if (
            requested_device in ("auto", "mps")
            and device == "cpu"
            and run_native_fastsurfer(
                project_dir,
                subject_id,
                t1_file,
                threads=n_threads,
                logger=logger,
                stop_event=runner.stop_event,
            )
        ):
            write_derived_outputs(mri_dir, logger=logger)
            return

        if requested_device == "mps":
            device, reason = _probe_device("mps", env)
        script = fastsurfer_script()
        if script is None:
            raise _missing_fastsurfer_error()
        if device == "cpu" and requested_device == "auto":
            logger.warning(
                "FastSurfer will use CPU because no accessible GPU passed its computation "
                "probe: %s. On Apple Silicon, enable Apple GPU in the desktop app "
                "to use the project-scoped native runtime.",
                reason,
            )
        else:
            logger.info("FastSurfer selected %s: %s", device, reason)
        cmd = [
            str(script),
            "--seg_only",
            # The image runs FastSurfer as root (the container's only user) and
            # run_fastsurfer.sh 2.x refuses that outright ("pass --allow_root")
            # -- the pre_fastsurfer smoke row failed on exactly this line.
            "--allow_root",
            "--no_cereb",
            "--no_hypothal",
            # Mandatory: see the module docstring's spike note.
            "--no_cc",
            "--sid",
            f"sub-{subject_id}",
            "--sd",
            str(subjects_root),
            "--t1",
            str(t1_file),
            "--device",
            device,
            # CPU aggregation avoids large prediction buffers exhausting GPU memory.
            "--viewagg_device",
            "cpu",
            "--batch",
            "1",
            "--threads",
            str(n_threads),
            "--py",
            fastsurfer_python(),
        ]

        logger.info(
            f"Running FastSurfer seg_only for subject {subject_id} "
            f"({n_threads} threads, device={device})"
        )
        exit_code = runner.run(cmd, logger=logger, env=env)

        if exit_code != 0:
            raise PreprocessError(
                f"FastSurfer failed for subject {subject_id} (exit {exit_code})."
            )

        if not seg_path.is_file():
            raise PreprocessError(
                f"FastSurfer reported success but {seg_path} was not written."
            )

        write_derived_outputs(mri_dir, logger=logger)

tit.pre.fastsurfer.fastsurfer_available

fastsurfer_available() -> bool

Return True when a runnable FastSurfer checkout is present.

Capability-probe shape (same contract as the server's other capability probes): filesystem-only, no subprocess, safe to call on any platform.

Source code in tit/pre/fastsurfer.py
def fastsurfer_available() -> bool:
    """Return ``True`` when a runnable FastSurfer checkout is present.

    Capability-probe shape (same contract as the server's other capability
    probes): filesystem-only, no subprocess, safe to call on any platform.
    """
    return fastsurfer_script() is not None

tit.pre.charm.run_charm

run_charm(project_dir: str, subject_id: str, *, logger, runner: CommandRunner | None = None, threads: int | None = None, options: dict | None = None) -> None

Run SimNIBS charm to generate a head mesh for a subject.

Creates an m2m directory at the standard BIDS derivatives location containing the volumetric head model required for TI simulations.

Parameters

project_dir : str BIDS project root. subject_id : str Subject identifier without the sub- prefix. logger : logging.Logger Logger used for progress and command output. runner : CommandRunner or None, optional Subprocess runner used to stream output.

Raises

PreprocessError If no T1 image is found, the m2m directory already exists, or charm exits with a non-zero code.

See Also

run_subject_atlas : Create atlas .annot files after CHARM. run_fastsurfer : FastSurfer deep segmentation.

Source code in tit/pre/charm.py
def run_charm(
    project_dir: str,
    subject_id: str,
    *,
    logger,
    runner: CommandRunner | None = None,
    threads: int | None = None,
    options: dict | None = None,
) -> None:
    """Run SimNIBS ``charm`` to generate a head mesh for a subject.

    Creates an m2m directory at the standard BIDS derivatives location
    containing the volumetric head model required for TI simulations.

    Parameters
    ----------
    project_dir : str
        BIDS project root.
    subject_id : str
        Subject identifier without the ``sub-`` prefix.
    logger : logging.Logger
        Logger used for progress and command output.
    runner : CommandRunner or None, optional
        Subprocess runner used to stream output.

    Raises
    ------
    PreprocessError
        If no T1 image is found, the m2m directory already exists, or
        ``charm`` exits with a non-zero code.

    See Also
    --------
    run_subject_atlas : Create atlas ``.annot`` files after CHARM.
    run_fastsurfer : FastSurfer deep segmentation.
    """
    from tit.telemetry import track_operation
    from tit import constants as _const

    with track_operation(_const.TELEMETRY_OP_PRE_CHARM):
        pm = get_path_manager(project_dir)

        simnibs_subject_dir = Path(pm.sub(subject_id))
        simnibs_subject_dir.mkdir(parents=True, exist_ok=True)
        m2m_dir = Path(pm.m2m(subject_id))

        t1_file, t2_file = _find_anat_files(subject_id)
        if not t1_file:
            bids_anat_dir = Path(pm.bids_anat(subject_id))
            raise PreprocessError(f"No T1 image found in {bids_anat_dir}")

        if m2m_dir.exists():
            raise PreprocessError(
                f"m2m output already exists at {m2m_dir}. "
                "Remove the directory manually before rerunning."
            )

        form_flag = _get_form_flag(t1_file)
        cmd = ["charm", form_flag, subject_id, str(t1_file)]
        if t2_file:
            cmd.append(str(t2_file))

        from tit.surfer_settings import effective_threads

        thread_count = effective_threads("charm", threads)
        logger.info(f"Running SimNIBS charm for subject {subject_id}")
        if runner is None:
            runner = CommandRunner()
        with tempfile.TemporaryDirectory(prefix="tit-charm-") as temporary:
            settings_file = Path(temporary) / "charm.ini"
            _write_thread_settings(settings_file, thread_count, options)
            cmd.extend(["--usesettings", str(settings_file)])
            env = {
                **os.environ,
                "OMP_NUM_THREADS": str(thread_count),
                "ITK_GLOBAL_DEFAULT_NUMBER_OF_THREADS": str(thread_count),
                # OpenBLAS falls back to OMP_NUM_THREADS when OPENBLAS_NUM_THREADS
                # is unset, so raising OMP_NUM_THREADS above also lets OpenBLAS
                # spawn thread_count threads per samseg/gems caller; with several
                # concurrent callers this overflows OpenBLAS's thread-metadata
                # table and deadlocks (seen on many-core Windows hosts).
                "OPENBLAS_NUM_THREADS": "1",
            }
            exit_code = runner.run(
                cmd, logger=logger, cwd=str(simnibs_subject_dir), env=env
            )

        if exit_code != 0:
            raise PreprocessError(
                f"charm failed for subject {subject_id} (exit {exit_code})."
            )

        copy_charm_report(project_dir, subject_id, logger=logger)

tit.pre.charm.run_subject_atlas

run_subject_atlas(project_dir: str, subject_id: str, *, logger, runner: CommandRunner | None = None) -> None

Run subject_atlas to create .annot files for a subject.

Should be called after run_charm completes successfully. Generates all three atlases: a2009s, DK40, and HCP_MMP1.

Parameters

project_dir : str BIDS project root. subject_id : str Subject identifier without the sub- prefix. logger : logging.Logger Logger used for progress and command output. runner : CommandRunner or None, optional Subprocess runner used to stream output.

Raises

PreprocessError If the m2m directory does not exist or subject_atlas fails.

See Also

run_charm : Generate the m2m head mesh (prerequisite).

Source code in tit/pre/charm.py
def run_subject_atlas(
    project_dir: str,
    subject_id: str,
    *,
    logger,
    runner: CommandRunner | None = None,
) -> None:
    """Run ``subject_atlas`` to create ``.annot`` files for a subject.

    Should be called after ``run_charm`` completes successfully.
    Generates all three atlases: a2009s, DK40, and HCP_MMP1.

    Parameters
    ----------
    project_dir : str
        BIDS project root.
    subject_id : str
        Subject identifier without the ``sub-`` prefix.
    logger : logging.Logger
        Logger used for progress and command output.
    runner : CommandRunner or None, optional
        Subprocess runner used to stream output.

    Raises
    ------
    PreprocessError
        If the m2m directory does not exist or ``subject_atlas`` fails.

    See Also
    --------
    run_charm : Generate the m2m head mesh (prerequisite).
    """

    pm = get_path_manager(project_dir)
    m2m_dir = Path(pm.m2m(subject_id))

    if not m2m_dir.exists():
        raise PreprocessError(f"m2m folder not found at {m2m_dir}. Run charm first.")

    # Output directory for atlas segmentation
    output_dir = m2m_dir / "segmentation"
    output_dir.mkdir(parents=True, exist_ok=True)

    if runner is None:
        runner = CommandRunner()

    logger.info(
        f"Running subject_atlas for subject {subject_id} with atlases: {', '.join(ATLASES)}"
    )

    for atlas in ATLASES:
        cmd = [
            "subject_atlas",
            "-a",
            atlas,
            "-o",
            str(output_dir),
            str(m2m_dir),
        ]

        logger.info(f"  Creating {atlas} atlas...")
        exit_code = runner.run(cmd, logger=logger)
        if exit_code != 0:
            raise PreprocessError(
                f"subject_atlas failed for atlas {atlas} (exit code {exit_code})"
            )

    logger.info(f"All atlases created successfully for subject {subject_id}")

tit.pre.tissue_analyzer.run_tissue_analysis

run_tissue_analysis(project_dir: str, subject_id: str, *, tissues: Iterable[str] = DEFAULT_TISSUES, logger: Logger) -> dict

Run tissue analysis for a subject.

Creates a TissueAnalyzer for each requested tissue type, computes volume and thickness statistics, and writes reports and visualizations.

Parameters

project_dir : str BIDS project root. subject_id : str Subject identifier without the sub- prefix. tissues : iterable of str, optional Tissue types to analyze. Defaults to ('bone', 'csf', 'skin'). logger : logging.Logger Logger for progress output.

Returns

dict Mapping of tissue name to per-tissue analysis results (volume, thickness, voxel counts, report path).

Raises

PreprocessError If the segmentation labeling file does not exist.

See Also

TissueAnalyzer : Low-level analysis for a single tissue type. run_pipeline : Full preprocessing pipeline.

Source code in tit/pre/tissue_analyzer.py
def run_tissue_analysis(
    project_dir: str,
    subject_id: str,
    *,
    tissues: Iterable[str] = DEFAULT_TISSUES,
    logger: logging.Logger,
) -> dict:
    """Run tissue analysis for a subject.

    Creates a ``TissueAnalyzer`` for each requested tissue type, computes
    volume and thickness statistics, and writes reports and visualizations.

    Parameters
    ----------
    project_dir : str
        BIDS project root.
    subject_id : str
        Subject identifier without the ``sub-`` prefix.
    tissues : iterable of str, optional
        Tissue types to analyze.  Defaults to ``('bone', 'csf', 'skin')``.
    logger : logging.Logger
        Logger for progress output.

    Returns
    -------
    dict
        Mapping of tissue name to per-tissue analysis results (volume,
        thickness, voxel counts, report path).

    Raises
    ------
    PreprocessError
        If the segmentation labeling file does not exist.

    See Also
    --------
    TissueAnalyzer : Low-level analysis for a single tissue type.
    run_pipeline : Full preprocessing pipeline.
    """
    pm = get_path_manager(project_dir)

    label_path = Path(pm.tissue_labeling(subject_id))
    if not label_path.exists():
        raise PreprocessError(f"labeling.nii.gz not found: {label_path}")

    output_root = Path(pm.ensure(pm.tissue_analysis_output(subject_id)))
    results = {}

    for tissue in tissues:
        if tissue not in TISSUE_CONFIGS:
            logger.warning(f"Unknown tissue type: {tissue}, skipping")
            continue

        output_dir = output_root / f"{tissue}_analysis"
        analyzer = TissueAnalyzer(label_path, output_dir, tissue, logger)
        results[tissue] = analyzer.analyze()

    return results

tit.pre.qsi.qsiprep.run_qsiprep

run_qsiprep(project_dir: str, subject_id: str, *, logger: Logger, output_resolution: float | None = None, cpus: int | None = None, memory_gb: int | None = None, omp_threads: int = QSI_DEFAULT_OMP_THREADS, image_tag: str = QSI_QSIPREP_IMAGE_TAG, skip_bids_validation: bool = True, denoise_method: str = 'dwidenoise', unringing_method: str = 'auto', mni_normalization: bool = False, runner: CommandRunner | None = None) -> None

Run QSIPrep preprocessing for a subject's DWI data.

This function spawns a QSIPrep Docker container as a sibling to the current SimNIBS container using Docker-out-of-Docker (DooD). QSIPrep needs an x86-64 Docker host (its SynthSeg step needs AVX); an arm64 host is refused before anything runs.

Susceptibility distortion correction is always on: TOPUP with the subject's reverse phase-encoding fieldmap (IntendedFor written into its sidecar when missing) or reverse-PE DWI series, else fieldmap-less SyN (--use-syn-sdc warn). DWI fieldmaps that exist but cannot be used stop the run instead of silently falling back.

Parameters

project_dir : str Path to the BIDS project root directory. subject_id : str Subject identifier (without 'sub-' prefix). logger : logging.Logger Logger for status messages. output_resolution : float or None, optional Isotropic output voxel size in mm. None (default) uses the native DWI voxel size (smallest axis, rounded to 0.1 mm). cpus : int, optional Number of CPUs to allocate. Default: 8. memory_gb : int, optional Memory limit in GB. Default: 32. omp_threads : int, optional Threads per process. Default: constants.QSI_DEFAULT_OMP_THREADS (min(cpu_count - 1, 8), matching QSIPrep's own default). image_tag : str, optional QSIPrep Docker image tag. Default from constants.QSI_QSIPREP_IMAGE_TAG. skip_bids_validation : bool, optional Skip BIDS validation. Default: True. denoise_method : str, optional Denoising method. Default: 'dwidenoise'. unringing_method : str, optional 'mrdegibbs', 'rpg', 'none' or 'auto' (default): rpg when the DWI sidecar has PartialFourier < 1, else mrdegibbs. mni_normalization : bool, optional Run the anatomical normalization to MNI (needed only for QSIRecon atlases and template-space specs). Default: False; forced on by SyN SDC. runner : CommandRunner | None, optional Command runner for subprocess execution.

Raises

PreprocessError If QSIPrep fails or prerequisites are not met.

Source code in tit/pre/qsi/qsiprep.py
def run_qsiprep(
    project_dir: str,
    subject_id: str,
    *,
    logger: logging.Logger,
    output_resolution: float | None = None,
    cpus: int | None = None,
    memory_gb: int | None = None,
    omp_threads: int = const.QSI_DEFAULT_OMP_THREADS,
    image_tag: str = const.QSI_QSIPREP_IMAGE_TAG,
    skip_bids_validation: bool = True,
    denoise_method: str = "dwidenoise",
    unringing_method: str = "auto",
    mni_normalization: bool = False,
    runner: CommandRunner | None = None,
) -> None:
    """
    Run QSIPrep preprocessing for a subject's DWI data.

    This function spawns a QSIPrep Docker container as a sibling to the
    current SimNIBS container using Docker-out-of-Docker (DooD). QSIPrep needs
    an x86-64 Docker host (its SynthSeg step needs AVX); an arm64 host is
    refused before anything runs.

    Susceptibility distortion correction is always on: TOPUP with the subject's
    reverse phase-encoding fieldmap (``IntendedFor`` written into its sidecar when
    missing) or reverse-PE DWI series, else fieldmap-less SyN
    (``--use-syn-sdc warn``). DWI fieldmaps that exist but cannot be used stop the
    run instead of silently falling back.

    Parameters
    ----------
    project_dir : str
        Path to the BIDS project root directory.
    subject_id : str
        Subject identifier (without 'sub-' prefix).
    logger : logging.Logger
        Logger for status messages.
    output_resolution : float or None, optional
        Isotropic output voxel size in mm. ``None`` (default) uses the native DWI
        voxel size (smallest axis, rounded to 0.1 mm).
    cpus : int, optional
        Number of CPUs to allocate. Default: 8.
    memory_gb : int, optional
        Memory limit in GB. Default: 32.
    omp_threads : int, optional
        Threads per process. Default: ``constants.QSI_DEFAULT_OMP_THREADS``
        (min(cpu_count - 1, 8), matching QSIPrep's own default).
    image_tag : str, optional
        QSIPrep Docker image tag. Default from ``constants.QSI_QSIPREP_IMAGE_TAG``.
    skip_bids_validation : bool, optional
        Skip BIDS validation. Default: True.
    denoise_method : str, optional
        Denoising method. Default: 'dwidenoise'.
    unringing_method : str, optional
        'mrdegibbs', 'rpg', 'none' or 'auto' (default): rpg when the DWI sidecar
        has ``PartialFourier`` < 1, else mrdegibbs.
    mni_normalization : bool, optional
        Run the anatomical normalization to MNI (needed only for QSIRecon atlases
        and template-space specs). Default: False; forced on by SyN SDC.
    runner : CommandRunner | None, optional
        Command runner for subprocess execution.

    Raises
    ------
    PreprocessError
        If QSIPrep fails or prerequisites are not met.
    """
    from tit.telemetry import track_operation
    from tit import constants as _const

    with track_operation(_const.TELEMETRY_OP_PRE_QSIPREP):
        logger.info(f"Starting QSIPrep for subject {subject_id}")
        ok, preflight_error = validate_dood_environment(
            project_dir, require_x86_64=True
        )
        if not ok:
            raise PreprocessError(f"QSI Docker preflight failed: {preflight_error}")

        # Validate DWI data exists. This runs before the container starts
        # because QSIPrep surfaces a bad gradient table or an incomplete
        # sidecar only after the anatomical workflow has finished, an hour in.
        is_valid, error_msg = validate_bids_dwi(project_dir, subject_id, logger)
        if not is_valid:
            raise PreprocessError(f"DWI validation failed: {error_msg}")

        is_valid, error_msg = ensure_total_readout_time(
            project_dir, subject_id, logger=logger
        )
        if not is_valid:
            raise PreprocessError(f"DWI sidecar validation failed: {error_msg}")

        pm = get_path_manager(project_dir)
        output_dir = Path(pm.qsiprep_subject(subject_id))
        # Docker `-v` can leave an empty directory behind; only a non-empty
        # one is a real output.
        if output_dir.exists() and any(output_dir.iterdir()):
            raise PreprocessError(
                f"QSIPrep output already exists at {output_dir}. "
                "Remove the directory manually before rerunning. The working "
                f"directory at {Path(pm.derivatives()) / '.qsiprep_work'} is kept "
                "on purpose: QSIPrep reuses the nodes that already finished, so "
                "a rerun after a failure skips the anatomical workflow. Delete "
                "it too only if you want to start from scratch."
            )

        sdc = plan_distortion_correction(project_dir, subject_id, logger=logger)
        if sdc.blocking_error:
            raise PreprocessError(sdc.blocking_error)
        logger.info(f"Distortion correction: {sdc.describe()}")
        if sdc.use_syn and not mni_normalization:
            logger.info(
                "MNI normalization enabled: SyN distortion correction needs the "
                "anat-to-MNI transform."
            )

        if unringing_method == "auto":
            unringing_method, reason = choose_unringing_method(project_dir, subject_id)
            logger.info(f"Unringing: {unringing_method} ({reason})")

        if output_resolution is None:
            output_resolution = native_dwi_resolution(project_dir, subject_id)
            if output_resolution is None:
                output_resolution = const.QSI_DEFAULT_OUTPUT_RESOLUTION
                logger.warning(
                    "Could not read the DWI voxel size; using "
                    f"{output_resolution:g} mm output resolution."
                )
            else:
                logger.info(
                    f"Output resolution: {output_resolution:g} mm (native DWI voxel size)"
                )

        # Create output directories
        output_dir.parent.mkdir(parents=True, exist_ok=True)
        work_dir = Path(pm.derivatives()) / ".qsiprep_work"
        work_dir.mkdir(parents=True, exist_ok=True)

        # Build configuration
        config = QSIPrepConfig(
            subject_id=subject_id,
            output_resolution=output_resolution,
            resources=ResourceConfig(
                cpus=cpus,
                memory_gb=memory_gb,
                omp_threads=omp_threads,
            ),
            image_tag=image_tag,
            skip_bids_validation=skip_bids_validation,
            denoise_method=denoise_method,
            unringing_method=unringing_method,
            use_syn_sdc=sdc.use_syn,
            mni_normalization=mni_normalization,
        )

        try:
            # Build Docker command
            builder = DockerCommandBuilder(project_dir)
            cmd = builder.build_qsiprep_cmd(config)
        except DockerBuildError as e:
            raise PreprocessError(f"Failed to build QSIPrep command: {e}")

        # Ensure image is available
        if not pull_image_if_needed(const.QSI_QSIPREP_IMAGE, image_tag, logger):
            raise PreprocessError(
                f"Failed to pull QSIPrep image: {const.QSI_QSIPREP_IMAGE}:{image_tag}"
            )

        # Log the command for debugging
        logger.debug(f"QSIPrep command: {' '.join(cmd)}")

        # Run the container
        if runner is None:
            runner = CommandRunner()

        logger.info(f"Running QSIPrep for subject {subject_id}...")
        returncode = runner.run(cmd, logger=logger)

        if returncode != 0:
            crash_count = _report_crashfiles(output_dir, logger)
            raise PreprocessError(
                _format_qsiprep_failure(returncode, runner, crash_count)
            )

        # Validate output
        is_valid, error_msg = validate_qsiprep_output(project_dir, subject_id)
        if not is_valid:
            raise PreprocessError(f"QSIPrep output validation failed: {error_msg}")

    logger.info(f"QSIPrep completed successfully for subject {subject_id}")

tit.pre.qsi.qsirecon.run_qsirecon

run_qsirecon(project_dir: str, subject_id: str, *, logger: Logger, recon_specs: list[str] | None = None, atlases: list[str] | None = None, use_gpu: bool = False, cpus: int | None = None, memory_gb: int | None = None, omp_threads: int = QSI_DEFAULT_OMP_THREADS, image_tag: str = QSI_QSIRECON_IMAGE_TAG, skip_odf_reports: bool = True, runner: CommandRunner | None = None) -> None

Run QSIRecon reconstruction for a subject's preprocessed DWI data.

This function spawns QSIRecon Docker containers as siblings to the current SimNIBS container using Docker-out-of-Docker (DooD).

QSIRecon is optional: it adds tractography, scalar maps and connectivity on top of QSIPrep output. The SimNIBS DTI tensor does not need it. Multiple reconstruction specs can be run sequentially.

Parameters

project_dir : str Path to the BIDS project root directory. subject_id : str Subject identifier (without 'sub-' prefix). logger : logging.Logger Logger for status messages. recon_specs : list[str] | None, optional List of reconstruction specifications to run. Default: ['dsi_studio_gqi'] (GQI scalar maps, no tractography). Other specs (mrtrix_, dipy_, amico_noddi, pyafq_*, etc.) remain available. atlases : list[str] | None, optional List of atlases for connectivity analysis. Default: None (no connectivity). Needs a QSIPrep run with MNI normalization enabled. use_gpu : bool, optional Enable GPU acceleration. Default: False. cpus : int | None, optional Number of CPUs to allocate. None = inherit from current container. memory_gb : int | None, optional Memory limit in GB. None = inherit from current container. omp_threads : int, optional Threads per process. Default: constants.QSI_DEFAULT_OMP_THREADS (min(cpu_count - 1, 8), matching QSIPrep's own default). image_tag : str, optional QSIRecon Docker image tag. Default from constants.QSI_QSIRECON_IMAGE_TAG. skip_odf_reports : bool, optional Skip ODF report generation. Default: True. runner : CommandRunner | None, optional Command runner for subprocess execution.

Raises

PreprocessError If QSIRecon fails or prerequisites are not met.

Source code in tit/pre/qsi/qsirecon.py
def run_qsirecon(
    project_dir: str,
    subject_id: str,
    *,
    logger: logging.Logger,
    recon_specs: list[str] | None = None,
    atlases: list[str] | None = None,
    use_gpu: bool = False,
    cpus: int | None = None,
    memory_gb: int | None = None,
    omp_threads: int = const.QSI_DEFAULT_OMP_THREADS,
    image_tag: str = const.QSI_QSIRECON_IMAGE_TAG,
    skip_odf_reports: bool = True,
    runner: CommandRunner | None = None,
) -> None:
    """
    Run QSIRecon reconstruction for a subject's preprocessed DWI data.

    This function spawns QSIRecon Docker containers as siblings to the
    current SimNIBS container using Docker-out-of-Docker (DooD).

    QSIRecon is optional: it adds tractography, scalar maps and connectivity on
    top of QSIPrep output. The SimNIBS DTI tensor does not need it. Multiple
    reconstruction specs can be run sequentially.

    Parameters
    ----------
    project_dir : str
        Path to the BIDS project root directory.
    subject_id : str
        Subject identifier (without 'sub-' prefix).
    logger : logging.Logger
        Logger for status messages.
    recon_specs : list[str] | None, optional
        List of reconstruction specifications to run. Default: ['dsi_studio_gqi']
        (GQI scalar maps, no tractography). Other specs (mrtrix_*, dipy_*,
        amico_noddi, pyafq_*, etc.) remain available.
    atlases : list[str] | None, optional
        List of atlases for connectivity analysis. Default: None (no
        connectivity). Needs a QSIPrep run with MNI normalization enabled.
    use_gpu : bool, optional
        Enable GPU acceleration. Default: False.
    cpus : int | None, optional
        Number of CPUs to allocate. None = inherit from current container.
    memory_gb : int | None, optional
        Memory limit in GB. None = inherit from current container.
    omp_threads : int, optional
        Threads per process. Default: ``constants.QSI_DEFAULT_OMP_THREADS``
        (min(cpu_count - 1, 8), matching QSIPrep's own default).
    image_tag : str, optional
        QSIRecon Docker image tag. Default from ``constants.QSI_QSIRECON_IMAGE_TAG``.
    skip_odf_reports : bool, optional
        Skip ODF report generation. Default: True.
    runner : CommandRunner | None, optional
        Command runner for subprocess execution.

    Raises
    ------
    PreprocessError
        If QSIRecon fails or prerequisites are not met.
    """
    if recon_specs is None:
        recon_specs = [const.QSI_DEFAULT_RECON_SPEC]

    from tit.telemetry import track_operation
    from tit import constants as _const

    with track_operation(_const.TELEMETRY_OP_PRE_QSIRECON):
        logger.info(
            f"Starting QSIRecon for subject {subject_id} with specs: {recon_specs}, atlases: {atlases}"
        )
        ok, preflight_error = validate_dood_environment(
            project_dir, require_gpu=use_gpu
        )
        if not ok:
            raise PreprocessError(f"QSI Docker preflight failed: {preflight_error}")

        # Validate QSIPrep output exists
        is_valid, error_msg = validate_qsiprep_output(project_dir, subject_id)
        if not is_valid:
            raise PreprocessError(
                f"QSIPrep output validation failed: {error_msg}. "
                "Run QSIPrep first before running QSIRecon."
            )

        # Atlases are warped from MNI; QSIPrep only writes that transform when its
        # (opt-in) MNI normalization ran.
        qsiprep_anat = Path(get_path_manager(project_dir).qsiprep_subject(subject_id))
        if atlases and not any(
            qsiprep_anat.glob("**/anat/*_to-MNI152NLin2009cAsym_*xfm.h5")
        ):
            raise PreprocessError(
                "QSIRecon atlases need QSIPrep's MNI normalization, which this "
                "subject's QSIPrep run skipped. Re-run QSIPrep with MNI "
                "normalization enabled, or run QSIRecon without atlases."
            )

        # No mkdir here — Docker's `-v` creates host directories automatically.
        # Creating them from SimNIBS fails on Docker Desktop due to phantom
        # bind-mount entries left by previous sibling containers.
        # QSIRecon 26 writes its reconstructions under
        # <out>/derivatives/qsirecon-<suffix>/sub-<id>/ and reserves
        # <out>/sub-<id>/ for logs and figures. Guarding on the latter would
        # block every rerun after the first attempt, including one that
        # crashed and produced nothing but a log.
        recon_root = Path(get_path_manager(project_dir).qsirecon()) / "derivatives"
        existing = [
            candidate
            for candidate in recon_root.glob(f"qsirecon-*/sub-{subject_id}")
            if any(candidate.iterdir())
        ]
        if existing:
            raise PreprocessError(
                "QSIRecon output already exists at "
                f"{', '.join(str(path) for path in sorted(existing))}. "
                "Remove those directories manually before rerunning."
            )

        # Build configuration
        config = QSIReconConfig(
            subject_id=subject_id,
            recon_specs=recon_specs,
            atlases=atlases,
            use_gpu=use_gpu,
            resources=ResourceConfig(
                cpus=cpus,
                memory_gb=memory_gb,
                omp_threads=omp_threads,
            ),
            image_tag=image_tag,
            skip_odf_reports=skip_odf_reports,
        )

        try:
            # Build Docker command builder
            builder = DockerCommandBuilder(project_dir)
        except DockerBuildError as e:
            raise PreprocessError(f"Failed to initialize Docker: {e}")

        # Ensure image is available
        if not pull_image_if_needed(const.QSI_QSIRECON_IMAGE, image_tag, logger):
            raise PreprocessError(
                f"Failed to pull QSIRecon image: {const.QSI_QSIRECON_IMAGE}:{image_tag}"
            )

        # Create runner if not provided
        if runner is None:
            runner = CommandRunner()

        # Run each recon spec
        for spec in recon_specs:
            logger.info(f"Running QSIRecon spec: {spec}")

            try:
                cmd = builder.build_qsirecon_cmd(config, spec)
            except DockerBuildError as e:
                raise PreprocessError(f"Failed to build QSIRecon command: {e}")

            # Log the command for debugging
            logger.debug(f"QSIRecon command: {' '.join(cmd)}")

            # Run the container
            logger.info(f"Running QSIRecon {spec} for subject {subject_id}...")
            returncode = runner.run(cmd, logger=logger)

            if returncode != 0:
                raise PreprocessError(
                    f"QSIRecon {spec} failed with exit code {returncode}"
                )

            logger.info(f"QSIRecon {spec} completed for subject {subject_id}")

        logger.info(f"QSIRecon completed successfully for subject {subject_id}")

tit.pre.qsi.dti_extractor.extract_dti_tensor

extract_dti_tensor(project_dir: str, subject_id: str, *, logger: Logger) -> Path

Fit, register and write DTI_coregT1_tensor.nii.gz for subject_id.

Reads QSIPrep output and the charm m2m folder; writes the tensor and DTI_coregT1_qc.json into the m2m folder, then a DTI QC report.

Raises

tit.pre.utils.PreprocessError Missing inputs, an existing tensor, an m2m T1 that is not the T1w QSIPrep used, or a failed QC gate (the QC JSON and the report are still written; the tensor is not).

Source code in tit/pre/qsi/dti_extractor.py
def extract_dti_tensor(
    project_dir: str,
    subject_id: str,
    *,
    logger: logging.Logger,
) -> Path:
    """Fit, register and write ``DTI_coregT1_tensor.nii.gz`` for *subject_id*.

    Reads QSIPrep output and the charm m2m folder; writes the tensor and
    ``DTI_coregT1_qc.json`` into the m2m folder, then a DTI QC report.

    Raises
    ------
    tit.pre.utils.PreprocessError
        Missing inputs, an existing tensor, an m2m T1 that is not the T1w QSIPrep
        used, or a failed QC gate (the QC JSON and the report are still written; the
        tensor is not).
    """
    import nibabel as nib
    from scipy.ndimage import binary_dilation

    pm = get_path_manager(project_dir)
    m2m_dir = Path(pm.m2m(subject_id))
    output_path = m2m_dir / const.FILE_DTI_TENSOR
    qc_path = m2m_dir / const.FILE_DTI_QC
    t1_path = m2m_dir / const.FILE_T1
    labels_path = m2m_dir / "final_tissues.nii.gz"
    for path, fix in ((t1_path, "Run charm first."), (labels_path, "Run charm first.")):
        if not path.is_file():
            raise PreprocessError(f"{path} not found. {fix}")
    if output_path.exists():
        raise PreprocessError(
            f"DTI tensor already exists at {output_path}. Remove it before rerunning."
        )
    raw_t1_path = _find_nifti(Path(pm.bids_anat(subject_id)), f"sub-{subject_id}_T1w")
    if raw_t1_path is None:
        raise PreprocessError(
            f"sub-{subject_id}_T1w not found in {pm.bids_anat(subject_id)}; the "
            "transform chain starts from the T1w QSIPrep used."
        )
    inputs = qsiprep_inputs(Path(pm.qsiprep_subject(subject_id)))

    t1 = nib.load(str(t1_path))
    raw = nib.load(str(raw_t1_path))
    if raw.shape[:3] != t1.shape[:3] or not np.allclose(
        raw.affine, t1.affine, atol=1e-3
    ):
        raise PreprocessError(
            f"The m2m T1 grid ({t1_path}) differs from the raw T1w QSIPrep used "
            f"({raw_t1_path}). The QSIPrep transform chain is only valid when charm "
            "ran on that same T1w file."
        )
    labels = np.asanyarray(nib.load(str(labels_path)).dataobj)
    labels = labels[..., 0] if labels.ndim == 4 else labels
    if labels.shape != t1.shape[:3]:
        raise PreprocessError(f"{labels_path} is not on the T1 grid {t1.shape[:3]}")

    logger.info(f"DTI: WLS tensor fit (b <= {DTI_BMAX:g}) on {inputs['dwi'].name}")
    tensors_acpc, acpc_affine, fit_mask = fit_tensor(
        inputs["dwi"], inputs["grad"], inputs["dwi_mask"]
    )

    chain = tm.acpc_to_t1_world(
        raw.affine, raw.shape[:3], read_itk_transform(inputs["xfm"])
    )
    acpc_t1 = nib.load(str(inputs["acpc_t1"]))
    acpc_mask = np.asanyarray(nib.load(str(inputs["acpc_mask"])).dataobj) > 0
    logger.info("DTI: checking the QSIPrep ACPC -> T1 chain against an NCC refinement")
    ncc, disp = check_registration(
        chain,
        np.asanyarray(acpc_t1.dataobj, dtype=np.float32),
        acpc_t1.affine,
        acpc_mask,
        np.asanyarray(t1.dataobj, dtype=np.float32),
        t1.affine,
    )

    logger.info("DTI: resampling onto the m2m T1 grid with tensor rotation")
    tensors, valid = resample_tensor(
        tensors_acpc, acpc_affine, fit_mask, chain, t1.shape[:3], t1.affine
    )
    del tensors_acpc
    wmgm = np.isin(labels, (1, 2))
    mask = (
        valid
        & binary_dilation(wmgm, iterations=MASK_DILATE_VOX)
        & np.isin(labels, BRAIN_LABELS)
    )
    tensors[~mask] = 0

    eigenvalues, _ = tm.eig_desc(tensors[mask])
    fa, md = tm.fa_md(eigenvalues)
    wm_in_mask = (labels == 1)[mask]
    qc = DtiQc(
        ncc_chain=float(ncc),
        chain_vs_ncc_mm=disp,
        pct_pd=(
            float(100 * (eigenvalues[:, -1] > 0).mean()) if len(eigenvalues) else 0.0
        ),
        pct_wm_covered=float(100 * mask[labels == 1].mean()),
        pct_gm_covered=float(100 * mask[labels == 2].mean()),
        pct_wmgm_zero=float(100 * (~mask[wmgm]).mean()),
        wm_md_median=float(np.median(md[wm_in_mask])) if wm_in_mask.any() else 0.0,
        wm_fa_median=float(np.median(fa[wm_in_mask])) if wm_in_mask.any() else 0.0,
        n_out_of_brain=int((mask & ~np.isin(labels, BRAIN_LABELS)).sum()),
        acpc_to_t1=chain.round(6).tolist(),
    )
    qc.gate()
    logger.info(
        f"DTI QC: {json.dumps({k: v for k, v in asdict(qc).items() if k not in ('acpc_to_t1', 'thresholds')})}"
    )
    stored = None
    if qc.passed:
        stored = tm.world_to_simnibs(tensors, t1.affine).astype(np.float32)
        stored[~mask] = 0
    t6w = np.zeros(t1.shape[:3] + (6,), np.float32)
    t6w[mask] = tm.unsym(tensors[mask])
    del tensors
    if stored is not None:
        _save_nifti_gz(stored, t1.affine, output_path)
        logger.info(f"DTI tensor saved to: {output_path}")
        del stored

    from .dti_advisories import DtiVolumes

    warp_path = m2m_dir / "toMNI" / "Conform2MNI_nonl.nii.gz"
    vols = DtiVolumes.from_arrays(
        t1.affine,
        np.asanyarray(t1.dataobj, dtype=np.float32),
        labels,
        t6w,
        (
            np.asanyarray(nib.load(str(warp_path)).dataobj)
            if warp_path.is_file()
            else None
        ),
    )
    del t6w
    record = asdict(qc)
    logger.info(
        "DTI: measuring advisories (orientation, flip test, residual shift, conductivity)"
    )
    record_advisories(
        record,
        vols,
        project_dir,
        subject_id,
        recorded_by="extract_dti_tensor",
        logger=logger,
    )
    qc_path.write_text(json.dumps(record, indent=1) + "\n")

    from tit.reporting.generators.dti_qc import create_dti_qc_report

    try:
        report = create_dti_qc_report(project_dir, subject_id, record, vols)
        logger.info(f"DTI QC report: {report}")
    except Exception as exc:  # the tensor and QC record stand without their report
        logger.error(f"DTI QC report could not be written: {exc}")
    if not qc.passed:
        raise PreprocessError(
            "DTI QC gate failed ("
            + ", ".join(qc.failures)
            + f"); see {qc_path} and the DTI QC report. "
            "The tensor was not written. Check QSIPrep's report for this subject, and "
            "that charm ran on the same T1w QSIPrep used."
        )
    return output_path