Skip to content

docker_builder

tit.pre.qsi.docker_builder

Docker command builder for QSI containers.

This module constructs Docker run commands for QSIPrep and QSIRecon, handling volume mounts, resource allocation, and pipeline arguments.

DockerBuildError

Bases: Exception

Raised when Docker command construction fails.

DockerPaths dataclass

DockerPaths(bids_dir: str = '/data', output_dir: str = '/out', work_dir: str = '/work', license_file: str = '/opt/freesurfer/license.txt')

Container-side mount points for QSI containers.

QSI containers expect BIDS data at /data and write outputs to /out.

Attributes

bids_dir : str Container path for BIDS input data. output_dir : str Container path for pipeline outputs. work_dir : str Container path for intermediate working files. license_file : str Container path for the FreeSurfer license.

DockerCommandBuilder

DockerCommandBuilder(project_dir: str, paths: DockerPaths | None = None)

Build Docker run commands for QSIPrep and QSIRecon containers.

Handles volume mounts (host ↔ container), resource limits (CPU / memory), FreeSurfer license staging, and custom pipeline YAML staging for Docker-outside-of-Docker (DooD) execution.

Parameters

project_dir : str Path to the BIDS project directory inside the current container (used to resolve relative paths). paths : DockerPaths or None, optional Container path configuration. Uses DockerPaths() defaults when None.

Attributes

project_dir : str Container-side BIDS project directory. paths : DockerPaths Resolved container path configuration.

Raises

DockerBuildError If Docker is not available or required paths cannot be resolved.

See Also

tit.pre.qsi.config : QSIPrepConfig and QSIReconConfig dataclasses.

Source code in tit/pre/qsi/docker_builder.py
def __init__(
    self,
    project_dir: str,
    paths: DockerPaths | None = None,
) -> None:
    self.project_dir = project_dir
    self.paths = paths or DockerPaths()

    self._host_project_dir = get_host_project_dir()
    self._host_license_path = self._stage_fs_license()

build_qsiprep_cmd

build_qsiprep_cmd(config: QSIPrepConfig) -> list[str]

Build Docker command for QSIPrep.

Parameters

config : QSIPrepConfig QSIPrep configuration.

Returns

list[str] Complete Docker command as a list of arguments.

Source code in tit/pre/qsi/docker_builder.py
def build_qsiprep_cmd(self, config: QSIPrepConfig) -> list[str]:
    """
    Build Docker command for QSIPrep.

    Parameters
    ----------
    config : QSIPrepConfig
        QSIPrep configuration.

    Returns
    -------
    list[str]
        Complete Docker command as a list of arguments.
    """
    image = f"{const.QSI_QSIPREP_IMAGE}:{config.image_tag}"
    inherited_cpus, inherited_mem_gb = get_inherited_dood_resources()
    effective_cpus = (
        config.resources.cpus
        if config.resources.cpus is not None
        else inherited_cpus
    )
    effective_mem_gb = (
        config.resources.memory_gb
        if config.resources.memory_gb is not None
        else inherited_mem_gb
    )
    # omp_threads defaults from the raw host CPU count; nthreads is
    # cgroup-aware, so clamp to avoid --omp-nthreads > --nthreads.
    effective_omp = min(config.resources.omp_threads, effective_cpus)

    # Build base docker run command
    cmd = [
        "docker",
        "run",
        "--rm",
        "--platform",
        "linux/amd64",
        "--name",
        f"qsiprep_{config.subject_id}_{uuid.uuid4().hex[:8]}",
        *self._label_args("qsiprep"),
    ]

    # Resource limits
    cmd.extend(
        [
            "--cpus",
            str(effective_cpus),
            "--memory",
            format_memory_limit(effective_mem_gb),
        ]
    )

    # Environment variables
    cmd.extend(["-e", f"OMP_NUM_THREADS={effective_omp}"])
    if self._host_license_path:
        cmd.extend(["-e", f"FS_LICENSE={self.paths.license_file}"])

    # Volume mounts - mount host project directory
    # BIDS data is at project root
    cmd.extend(["-v", f"{self._host_project_dir}:{self.paths.bids_dir}:ro"])

    qsiprep_output = str(Path(self._host_project_dir) / "derivatives" / "qsiprep")
    cmd.extend(["-v", f"{qsiprep_output}:{self.paths.output_dir}"])

    work_dir = str(Path(self._host_project_dir) / "derivatives" / ".qsiprep_work")
    cmd.extend(["-v", f"{work_dir}:{self.paths.work_dir}"])

    if self._host_license_path:
        cmd.extend(
            ["-v", f"{self._host_license_path}:{self.paths.license_file}:ro"]
        )

    cmd.append(image)

    # QSIPrep arguments
    cmd.extend(
        [
            self.paths.bids_dir,
            self.paths.output_dir,
            "participant",
        ]
    )

    cmd.extend(["--participant-label", config.subject_id])
    cmd.extend(["--output-resolution", str(config.output_resolution)])
    cmd.extend(["-w", self.paths.work_dir])
    cmd.extend(["--nthreads", str(effective_cpus)])
    cmd.extend(["--omp-nthreads", str(effective_omp)])
    cmd.extend(["--mem-mb", str(effective_mem_gb * 1024)])

    if self._host_license_path:
        cmd.extend(["--fs-license-file", self.paths.license_file])

    if config.skip_bids_validation:
        cmd.append("--skip-bids-validation")

    cmd.extend(["--denoise-method", config.denoise_method])
    cmd.extend(["--unringing-method", config.unringing_method])
    # Stated explicitly so an upstream default change cannot move our results.
    cmd.extend(["--hmc-model", "eddy", "--pepolar-method", "TOPUP"])
    cmd.extend(["--b0-threshold", f"{const.QSI_B0_THRESHOLD:g}"])
    if config.use_syn_sdc:
        cmd.extend(["--use-syn-sdc", "warn"])
    # SyN SDC places its fieldmap prior through the anat->MNI transform, so it
    # needs the normalization; otherwise only QSIRecon atlases do.
    if not (config.mni_normalization or config.use_syn_sdc):
        cmd.append("--skip-anat-based-spatial-normalization")

    return cmd

build_qsirecon_cmd

build_qsirecon_cmd(config: QSIReconConfig, recon_spec: str) -> list[str]

Build Docker command for QSIRecon with a specific recon spec.

Parameters

config : QSIReconConfig QSIRecon configuration. recon_spec : str The reconstruction specification to use.

Returns

list[str] Complete Docker command as a list of arguments.

Source code in tit/pre/qsi/docker_builder.py
def build_qsirecon_cmd(self, config: QSIReconConfig, recon_spec: str) -> list[str]:
    """
    Build Docker command for QSIRecon with a specific recon spec.

    Parameters
    ----------
    config : QSIReconConfig
        QSIRecon configuration.
    recon_spec : str
        The reconstruction specification to use.

    Returns
    -------
    list[str]
        Complete Docker command as a list of arguments.
    """
    image = f"{const.QSI_QSIRECON_IMAGE}:{config.image_tag}"
    inherited_cpus, inherited_mem_gb = get_inherited_dood_resources()
    effective_cpus = (
        config.resources.cpus
        if config.resources.cpus is not None
        else inherited_cpus
    )
    effective_mem_gb = (
        config.resources.memory_gb
        if config.resources.memory_gb is not None
        else inherited_mem_gb
    )
    # omp_threads defaults from the raw host CPU count; nthreads is
    # cgroup-aware, so clamp to avoid --omp-nthreads > --nthreads.
    effective_omp = min(config.resources.omp_threads, effective_cpus)

    cmd = [
        "docker",
        "run",
        "--rm",
        "--platform",
        "linux/amd64",
        "--name",
        f"qsirecon_{config.subject_id}_{recon_spec.replace('-', '_')}_{uuid.uuid4().hex[:8]}",
        *self._label_args("qsirecon"),
    ]

    if config.use_gpu:
        cmd.extend(["--gpus", "all"])

    cmd.extend(
        [
            "--cpus",
            str(effective_cpus),
            "--memory",
            format_memory_limit(effective_mem_gb),
        ]
    )

    cmd.extend(["-e", f"OMP_NUM_THREADS={effective_omp}"])
    if self._host_license_path:
        cmd.extend(["-e", f"FS_LICENSE={self.paths.license_file}"])

    qsiprep_output = str(Path(self._host_project_dir) / "derivatives" / "qsiprep")
    cmd.extend(["-v", f"{qsiprep_output}:{self.paths.bids_dir}:ro"])

    qsirecon_output = str(Path(self._host_project_dir) / "derivatives" / "qsirecon")
    cmd.extend(["-v", f"{qsirecon_output}:{self.paths.output_dir}"])

    work_dir = str(Path(self._host_project_dir) / "derivatives" / ".qsirecon_work")
    cmd.extend(["-v", f"{work_dir}:{self.paths.work_dir}"])

    if self._host_license_path:
        cmd.extend(
            ["-v", f"{self._host_license_path}:{self.paths.license_file}:ro"]
        )

    # Determine recon spec and stage custom YAML before appending image,
    # so we can add the file mount with the other -v flags.
    container_spec = recon_spec
    if not config.atlases and recon_spec in _CUSTOM_PIPELINE_MAP:
        container_spec, host_yaml = self._stage_custom_pipeline(
            _CUSTOM_PIPELINE_MAP[recon_spec]
        )
        cmd.extend(["-v", f"{host_yaml}:{container_spec}:ro"])

    cmd.append(image)

    cmd.extend(
        [
            self.paths.bids_dir,
            self.paths.output_dir,
            "participant",
        ]
    )

    cmd.extend(["--participant-label", config.subject_id])
    cmd.extend(["--recon-spec", container_spec])
    cmd.extend(["--input-type", "qsiprep"])
    cmd.extend(["-w", self.paths.work_dir])
    cmd.extend(["--nthreads", str(effective_cpus)])
    cmd.extend(["--omp-nthreads", str(effective_omp)])
    cmd.extend(["--mem-mb", str(effective_mem_gb * 1024)])

    if self._host_license_path:
        cmd.extend(["--fs-license-file", self.paths.license_file])

    if config.atlases:
        cmd.append("--atlases")
        cmd.extend(config.atlases)

    if config.skip_odf_reports:
        cmd.append("--skip-odf-reports")

    return cmd

get_output_dir

get_output_dir(pipeline: str) -> Path

Get the output directory path for a pipeline.

Parameters

pipeline : str Pipeline name ('qsiprep' or 'qsirecon').

Returns

Path Path to the output directory.

Source code in tit/pre/qsi/docker_builder.py
def get_output_dir(self, pipeline: str) -> Path:
    """
    Get the output directory path for a pipeline.

    Parameters
    ----------
    pipeline : str
        Pipeline name ('qsiprep' or 'qsirecon').

    Returns
    -------
    Path
        Path to the output directory.
    """
    return Path(self._host_project_dir) / "derivatives" / pipeline

resolve_fs_license_path

resolve_fs_license_path() -> Path | None

Return a readable FreeSurfer license file, or None.

Used by optional FreeSurfer reconstruction/subregions and by QSI workflows whose images require FreeSurfer. The core FastSurfer workflow needs none.

Optional overrides resolve first: $FS_LICENSE, the app's stored override, then :data:tit.constants.FS_LICENSE_PATH. Every installed toolbox includes its own license as the default; no user registration or configuration is required. Empty placeholders do not override it. Paths resolve on the server before being staged onto the shared project mount for sibling containers.

Source code in tit/pre/qsi/docker_builder.py
def resolve_fs_license_path() -> Path | None:
    """Return a readable FreeSurfer license file, or ``None``.

    Used by optional FreeSurfer reconstruction/subregions and by QSI workflows
    whose images require FreeSurfer. The core FastSurfer workflow needs none.

    Optional overrides resolve first: ``$FS_LICENSE``, the app's stored override,
    then :data:`tit.constants.FS_LICENSE_PATH`. Every installed toolbox includes
    its own license as the default; no user registration or configuration is
    required. Empty placeholders do not override it. Paths resolve on the server
    before being staged onto the shared project mount for sibling containers.
    """
    from tit.surfer_settings import BUNDLED_FS_LICENSE_PATH, freesurfer_license_path

    candidates = [
        os.environ.get("FS_LICENSE"),
        str(freesurfer_license_path()),
        const.FS_LICENSE_PATH,
        str(BUNDLED_FS_LICENSE_PATH),
    ]
    for candidate in candidates:
        if not candidate:
            continue
        path = Path(candidate)
        try:
            if path.is_file() and path.stat().st_size > 0:
                return path
        except OSError:
            continue
    return None