def run_freesurfer(
subject_id: str,
*,
recon_all: bool = True,
subregions: list[str] | None = None,
threads: int | None = None,
runner: CommandRunner,
logger: logging.Logger,
) -> None:
"""Run recon-all and/or subregions, retaining outputs but removing workers.
Project data is bind-mounted; no Docker data volume is created. Job labels
let cancellation also reap workers if the Python process is terminated.
"""
subject_id = validate_subject_id(subject_id)
targets = list(dict.fromkeys(subregions or []))
if set(targets) - SUBREGIONS:
raise PreprocessError(
"FreeSurfer subregions must be thalamus or hippo-amygdala."
)
if not recon_all and not targets:
raise PreprocessError("Select recon-all or at least one FreeSurfer subregion.")
pm = get_path_manager()
project = Path(pm.project_dir).resolve()
host_root = os.environ.get("LOCAL_PROJECT_DIR")
if not host_root and Path("/.dockerenv").exists():
raise PreprocessError(
"LOCAL_PROJECT_DIR is required to launch FreeSurfer from Docker."
)
host_project = Path(host_root or str(project))
if not host_project.is_absolute():
raise PreprocessError("LOCAL_PROJECT_DIR must be an absolute host path.")
if not recon_all:
validate_reconstruction(subject_id)
license_path = resolve_fs_license_path()
if license_path is None:
stage = "recon-all" if recon_all else "subregion segmentation"
raise PreprocessError(
f"FreeSurfer {stage} cannot find the license supplied by TI-Toolbox. "
"Repair or update the TI-Toolbox installation; no personal license is required."
)
subjects_dir = Path(pm.freesurfer())
subject = Path(pm.freesurfer_subject(subject_id))
commands = []
if recon_all:
args = ["recon-all", "-s", f"sub-{subject_id}", "-sd", str(subjects_dir)]
# Resume existing reconstruction without importing T1 a second time.
if not (subject / "mri/orig/001.mgz").is_file():
t1, _ = _find_anat_files(subject_id)
if not t1:
raise PreprocessError(f"No T1 file found in {pm.bids_anat(subject_id)}")
if not Path(t1).resolve().is_relative_to(project):
raise PreprocessError(
"FreeSurfer T1 must be inside the project directory."
)
args.extend(["-i", str(t1)])
commands.append(args)
cpus, memory = get_inherited_dood_resources()
# Match the scheduler's FreeSurfer reservation, not the whole server budget.
from tit.surfer_settings import effective_threads
cpus = min(cpus, effective_threads("freesurfer", threads))
if memory < 16:
raise PreprocessError(
"FreeSurfer requires at least 16 GiB of available container memory."
)
memory = 16
if commands:
commands[0].extend(["-all", "-parallel", "-openmp", str(cpus)])
for target in targets:
commands.append(
[
"segment_subregions",
target,
"--cross",
f"sub-{subject_id}",
"--sd",
str(subjects_dir),
"--threads",
str(cpus),
]
)
subjects_dir.mkdir(parents=True, exist_ok=True)
# A unique temporary bind avoids races between concurrent jobs' licenses.
with tempfile.TemporaryDirectory(prefix=".freesurfer-", dir=project) as stage:
staged_license = Path(stage) / "license.txt"
shutil.copyfile(license_path, staged_license)
staged_license.chmod(0o600)
host_license = host_project / staged_license.relative_to(project)
for index, command in enumerate(commands):
if command[0] == "segment_subregions":
validate_reconstruction(subject_id)
name = f"tit-freesurfer-{uuid.uuid4().hex}"
argv = [
"docker",
"run",
"--rm",
"--platform",
"linux/amd64",
"--name",
name,
"--label",
"tit.kind=freesurfer",
"--cpus",
str(cpus),
"--memory",
format_memory_limit(memory),
]
if job_id := os.environ.get("TIT_JOB_ID"):
argv.extend(["--label", f"tit.job_id={job_id}"])
argv.extend(
[
"-e",
"FS_LICENSE=/run/license.txt",
"-e",
f"OMP_NUM_THREADS={cpus}",
"-v",
f"{host_project}:{project}",
"-v",
f"{host_license}:/run/license.txt:ro",
IMAGE,
*command,
]
)
logger.info("Running FreeSurfer %s for sub-%s", command[0], subject_id)
try:
code = runner.run(argv, logger=logger)
if code != 0:
raise PreprocessError(
f"FreeSurfer {command[0]} failed for sub-{subject_id} (exit {code})."
)
finally:
_remove_container(name, logger)
if index == 0 and recon_all:
validate_reconstruction(subject_id)