Skip to content

fastsurfer

tit.pre.fastsurfer

FastSurfer --seg_only deep segmentation.

Replaces the FreeSurfer recon-all stage as the source of a voxel-space cortical+subcortical parcellation (aparc.DKTatlas+aseg). FastSurfer is Apache-2.0, PyTorch-only, needs no FreeSurfer binaries and no MATLAB runtime, and its label ids are byte-identical to FreeSurfer's colour table -- so every existing reader (VoxelAtlasManager, roi_spec, the analyzer) works on its output unchanged.

Public API

fastsurfer_available Capability probe: is a runnable FastSurfer checkout present? fastsurfer_home Resolved FASTSURFER_HOME (env, else /opt/fastsurfer). run_fastsurfer Run run_fastsurfer.sh --seg_only for one subject.

Notes

Measured on this project's own spike (docs/dev/DECISIONS.md ยง 2026-09-03 (One Docker image and a real development loop), sub-ernie, Apple M2, 8 threads, CPU only): ~5 min for the segmentation itself, 4.84 GiB peak RSS, mean Dice 0.922 (14 subcortical) / 0.914 (20 cortical DKT) against real recon-all output on the same subject. --no_cc is mandatory: without it FastSurfer downloads 81 MB of corpus-callosum checkpoints the toolbox has no use for, and the CC module crashed in that spike after the segmentation was already written.

See Also

tit.pre.charm : SimNIBS charm head-mesh generation (runs in parallel). tit.pre.structural.run_pipeline : Full preprocessing pipeline. tit.atlas.segstats : The LUT/label-listing code reused for the sidecar.

fastsurfer_home

fastsurfer_home() -> Path

Return the resolved FastSurfer checkout directory.

$FASTSURFER_HOME when set and non-empty, else :data:DEFAULT_FASTSURFER_HOME. The directory is not required to exist -- use :func:fastsurfer_available for that.

Source code in tit/pre/fastsurfer.py
def fastsurfer_home() -> Path:
    """Return the resolved FastSurfer checkout directory.

    ``$FASTSURFER_HOME`` when set and non-empty, else
    :data:`DEFAULT_FASTSURFER_HOME`. The directory is not required to exist
    -- use :func:`fastsurfer_available` for that.
    """
    return Path(os.environ.get(ENV_FASTSURFER_HOME) or DEFAULT_FASTSURFER_HOME)

fastsurfer_script

fastsurfer_script() -> Path | None

Return the path to run_fastsurfer.sh, or None if absent.

Source code in tit/pre/fastsurfer.py
def fastsurfer_script() -> Path | None:
    """Return the path to ``run_fastsurfer.sh``, or ``None`` if absent."""
    script = fastsurfer_home() / RUN_SCRIPT
    return script if script.is_file() else None

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

fastsurfer_python

fastsurfer_python() -> str

Return the interpreter passed to FastSurfer's --py flag.

Source code in tit/pre/fastsurfer.py
def fastsurfer_python() -> str:
    """Return the interpreter passed to FastSurfer's ``--py`` flag."""
    return os.environ.get(ENV_FASTSURFER_PYTHON) or DEFAULT_FASTSURFER_PYTHON

resolve_threads

resolve_threads(threads: int | None = None) -> int

Resolve explicit, environment, or user-wide automatic thread settings.

Source code in tit/pre/fastsurfer.py
def resolve_threads(threads: int | None = None) -> int:
    """Resolve explicit, environment, or user-wide automatic thread settings."""
    from tit.surfer_settings import effective_threads

    return effective_threads("fastsurfer", threads)

resolve_device

resolve_device() -> str

Use FastSurfer's runtime hardware detection unless explicitly overridden.

Source code in tit/pre/fastsurfer.py
def resolve_device() -> str:
    """Use FastSurfer's runtime hardware detection unless explicitly overridden."""
    device = (os.environ.get(ENV_FASTSURFER_DEVICE) or "auto").strip().lower()
    if not re.fullmatch(r"auto|cpu|mps|cuda(?::[0-9]+)?", device):
        raise PreprocessError(
            f"{ENV_FASTSURFER_DEVICE} must be auto, cpu, mps, or cuda[:index]."
        )
    return device

inference_environment

inference_environment() -> dict[str, str]

Enable upstream's required MPS fallback without changing the server env.

Source code in tit/pre/fastsurfer.py
def inference_environment() -> dict[str, str]:
    """Enable upstream's required MPS fallback without changing the server env."""
    env = os.environ.copy()
    env.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
    return env

write_derived_outputs

write_derived_outputs(mri_dir: Path, *, logger) -> None

Write the NIfTI copy and label sidecar next to FastSurfer's .mgz.

Split out of :func:run_fastsurfer so a project whose segmentation was produced by an earlier run (or by hand) can be brought up to the layout the atlas readers expect without re-running the network.

Raises

PreprocessError If mri_dir holds no :data:SEG_FILENAME.

Source code in tit/pre/fastsurfer.py
def write_derived_outputs(mri_dir: Path, *, logger) -> None:
    """Write the NIfTI copy and label sidecar next to FastSurfer's ``.mgz``.

    Split out of :func:`run_fastsurfer` so a project whose segmentation was
    produced by an earlier run (or by hand) can be brought up to the layout
    the atlas readers expect without re-running the network.

    Raises
    ------
    PreprocessError
        If *mri_dir* holds no :data:`SEG_FILENAME`.
    """
    seg_path = mri_dir / SEG_FILENAME
    if not seg_path.is_file():
        raise PreprocessError(f"FastSurfer segmentation not found at {seg_path}.")

    nifti_path = mri_dir / SEG_NIFTI_FILENAME
    if not nifti_path.is_file():
        _write_nifti_copy(seg_path, nifti_path, logger=logger)

    labels_path = mri_dir / SEG_LABELS_FILENAME
    if not labels_path.is_file():
        _write_labels_sidecar(seg_path, labels_path, logger=logger)

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)