Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CorpusCallosum/shape/subsegment_contour.py
Original file line number Diff line number Diff line change
Expand Up @@ -880,8 +880,8 @@ def transform_to_acpc_standard(
norms_product = np.linalg.norm(ac_pc_vec) * np.linalg.norm(posterior_vector)
theta = np.arccos(dot_product / norms_product)

# Determine the sign of the angle using cross product
cross_product = np.cross(ac_pc_vec, posterior_vector)
# Determine the sign of the angle using the signed z-component of the 2D cross product.
cross_product = ac_pc_vec[0] * posterior_vector[1] - ac_pc_vec[1] * posterior_vector[0]
if cross_product < 0:
theta = -theta

Expand Down
4 changes: 2 additions & 2 deletions CorpusCallosum/utils/mapping_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ def correct_nodding(ac_pt: Vector2d, pc_pt: Vector2d) -> AffineMatrix3x3:
norms_product = np.linalg.norm(ac_pc_vec) * np.linalg.norm(posterior_vector)
theta = np.arccos(dot_product / norms_product)

# Determine the sign of the angle using cross product
cross_product = np.cross(ac_pc_vec, posterior_vector)
# Determine the sign of the angle using the signed z-component of the 2D cross product.
cross_product = ac_pc_vec[0] * posterior_vector[1] - ac_pc_vec[1] * posterior_vector[0]
if cross_product < 0:
theta = -theta

Expand Down
160 changes: 160 additions & 0 deletions FastSurferCNN/utils/bids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Copyright 2026 Image Analysis Lab, German Center for Neurodegenerative Diseases (DZNE), Bonn
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Discovery and grouping of subjects/sessions from a BIDS dataset for run_fastsurfer_bids.py."""

import json
import logging
from dataclasses import dataclass, field
from pathlib import Path

LOGGER = logging.getLogger(__name__)


@dataclass
class BidsSession:
"""A single anat session found for a subject (may be the subject's only session)."""

session_id: str | None
t1w: Path
t2w: Path | None = None


@dataclass
class BidsSubject:
"""A subject with one or more sessions, plus its processing mode."""

subject_id: str
sessions: list[BidsSession] = field(default_factory=list)

@property
def is_longitudinal(self) -> bool:
"""Return True if this subject has more than one session with a T1w image."""
return len(self.sessions) > 1

def output_id(self, session: BidsSession) -> str:
"""Return the FastSurfer subject/timepoint id for a given session of this subject."""
if session.session_id is None:
return self.subject_id
return f"{self.subject_id}_ses-{session.session_id}"


def _require_pybids():
try:
import bids as pybids
except ImportError as e:
raise ImportError(
"The 'bids' (pybids) package is required for BIDS support but is not installed. "
"Install it with `pip install fastsurfer[bids]` or `pip install pybids`."
) from e
return pybids


def find_subjects(
bids_dir: Path,
participant_labels: list[str] | None = None,
session_labels: list[str] | None = None,
validate: bool = True,
) -> list[BidsSubject]:
"""
Discover subjects, sessions, and their T1w/T2w images in a BIDS dataset.

Parameters
----------
bids_dir : Path
Path to the root of a BIDS-valid dataset.
participant_labels : list[str], optional
If given, only include these subject labels (without the 'sub-' prefix).
session_labels : list[str], optional
If given, only include these session labels (without the 'ses-' prefix).
validate : bool, default=True
Whether pybids should validate the dataset against the BIDS spec.

Returns
-------
list[BidsSubject]
Subjects found, each with the sessions that have a usable T1w image. Subjects
with no T1w anywhere in the dataset are omitted (with a warning).
"""
pybids = _require_pybids()
layout = pybids.BIDSLayout(str(bids_dir), validate=validate)

subject_ids = layout.get_subjects()
if participant_labels:
wanted = {label.removeprefix("sub-") for label in participant_labels}
missing = wanted - set(subject_ids)
if missing:
raise ValueError(f"Requested participant label(s) not found in {bids_dir}: {sorted(missing)}")
subject_ids = [s for s in subject_ids if s in wanted]

subjects = []
for subject_id in subject_ids:
session_ids = layout.get_sessions(subject=subject_id)
if session_labels:
wanted_ses = {label.removeprefix("ses-") for label in session_labels}
session_ids = [s for s in session_ids if s in wanted_ses]
# a dataset without a session level yields session_ids == [], represent as [None]
iter_sessions: list[str | None] = list(session_ids) if session_ids else [None]

sessions = []
for session_id in iter_sessions:
query = {"subject": subject_id, "suffix": "T1w", "extension": [".nii", ".nii.gz"]}
if session_id is not None:
query["session"] = session_id
t1w_files = layout.get(**query)
if not t1w_files:
continue
if len(t1w_files) > 1:
LOGGER.warning(
"Subject sub-%s%s has multiple T1w images, using the first: %s",
subject_id,
f" session ses-{session_id}" if session_id else "",
[f.path for f in t1w_files],
)
t1w_path = Path(t1w_files[0].path)

t2w_query = dict(query, suffix="T2w")
t2w_files = layout.get(**t2w_query)
t2w_path = Path(t2w_files[0].path) if t2w_files else None

sessions.append(BidsSession(session_id=session_id, t1w=t1w_path, t2w=t2w_path))

if not sessions:
LOGGER.warning("Subject sub-%s has no T1w image, skipping.", subject_id)
continue
subjects.append(BidsSubject(subject_id=f"sub-{subject_id}", sessions=sessions))

return subjects


def write_derivatives_dataset_description(output_dir: Path, fastsurfer_version: str) -> None:
"""
Write a minimal BIDS-derivatives dataset_description.json into output_dir.

Parameters
----------
output_dir : Path
Directory to write dataset_description.json into. Created if it does not exist.
fastsurfer_version : str
FastSurfer version string to record as GeneratedBy.Version.
"""
output_dir.mkdir(parents=True, exist_ok=True)
description = {
"Name": "FastSurfer Output",
"BIDSVersion": "1.8.0",
"DatasetType": "derivative",
"GeneratedBy": [{"Name": "FastSurfer", "Version": fastsurfer_version}],
}
with open(output_dir / "dataset_description.json", "w") as f:
json.dump(description, f, indent=2)
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ All installation methods use the `run_fastsurfer.sh` call interface (replace the

[Example 3](doc/overview/EXAMPLES.md#example-3-native-fastsurfer-on-subjectx-with-parallel-processing-of-hemis) also illustrates the running the FastSurfer pipeline natively.

If your input data is organized as a [BIDS](https://bids.neuroimaging.io/)-valid dataset, you can use the
[`run_fastsurfer_bids.py`](doc/scripts/BIDS.md) BIDS-App entrypoint instead, which discovers subjects/sessions
automatically:
```bash
./run_fastsurfer_bids.py /path/to/bids_dataset /path/to/output_dir participant \
--participant_label 01 02 --fs_license /path/to/license.txt
```
See the [BIDS documentation](doc/scripts/BIDS.md) for details.

<!-- start of examples -->
Examples
--------
Expand Down
64 changes: 64 additions & 0 deletions doc/scripts/BIDS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
BIDS: run_fastsurfer_bids.py
=============================

`run_fastsurfer_bids.py` is a [BIDS-App](https://bids-apps.neuroimaging.io/about/)-style entrypoint for FastSurfer.
It discovers subjects and sessions directly from a BIDS-valid dataset and delegates processing to the existing
FastSurfer entrypoints ([`brun_fastsurfer.sh`](BATCH.md) for cross-sectional subjects,
[`long_fastsurfer.sh`](long_fastsurfer.rst) for subjects with multiple sessions).

Installation
------------
The BIDS entrypoint requires [pybids](https://bids-standard.github.io/pybids/), which is not installed by default.
Install it with:

```
pip install fastsurfer[bids]
```

Usage
-----
```{command-output} ./run_fastsurfer_bids.py --help
:cwd: /../
```

Basic example
--------------
```
./run_fastsurfer_bids.py /data/my_bids_dataset /data/fastsurfer_output participant \
--participant_label 01 02 --fs_license /data/license.txt -- --parallel
```

This processes `sub-01` and `sub-02` from the BIDS dataset at `/data/my_bids_dataset`, writing FreeSurfer-style
subject directories into `/data/fastsurfer_output`. Any options after a literal `--` are passed through unchanged to
the underlying `run_fastsurfer.sh`/`brun_fastsurfer.sh`/`long_fastsurfer.sh` calls (see [RUN_FASTSURFER.md](RUN_FASTSURFER.md)
and [BATCH.md](BATCH.md) for the full set of supported options).

Session and longitudinal handling
----------------------------------
- Subjects with a single session (or no `ses-` level in the dataset) are processed cross-sectionally, one call to
`brun_fastsurfer.sh` per selected subject batch.
- Subjects with **two or more** sessions are, by default, processed as a longitudinal cohort via `long_fastsurfer.sh`
(person-specific template `sub-X`, timepoints `sub-X_ses-Y`).
- `--cross_sectional` forces every session of every subject to be processed independently, even if multiple sessions
are present.
- `--longitudinal` forces the longitudinal pipeline; subjects with only one session fall back to cross-sectional
processing with a warning.
- Passing `--seg_only` or `--surf_only` after the literal `--` also forces cross-sectional processing for multi-session
subjects, because these modes are not supported by `long_fastsurfer.sh`.

T1w and T2w input
------------------
Only `*_T1w.nii[.gz]` and `*_T2w.nii[.gz]` anatomical images are considered. When a `T2w` image is present for a
session, it is passed as `--t2` to enable the [HypVINN](../overview/OUTPUT_FILES.md#hypvinn-module) hypothalamus
module. This is only supported in the cross-sectional pipeline; `long_fastsurfer.sh` does not accept `--t2`, so T2w
images for longitudinal subjects are ignored with a warning.

Output
------
The `output_dir` is used directly as FastSurfer's `SUBJECTS_DIR`. A minimal BIDS-derivatives
`dataset_description.json` is written into `output_dir`.

Dry run
-------
Pass `--dry_run` to print the commands that would be executed (including the generated subject-list file contents)
without running anything, e.g. to sanity-check subject/session discovery before committing to a full run.
1 change: 1 addition & 0 deletions doc/scripts/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Scripts
RUN_FASTSURFER.md
long_fastsurfer.rst
BATCH.md
BIDS.md
SLURM.md
advanced
util
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,13 @@ qc = [
lit = [
'neurolit>=0.6.1',
]
bids = [
'pybids>=0.16',
]
container = [
'fastsurfer[qc]',
'fastsurfer[lit]',
'fastsurfer[bids]',
]
doc = [
'fastsurfer[qc]',
Expand Down Expand Up @@ -110,6 +114,7 @@ all = [
'fastsurfer[style]',
'fastsurfer[qc]',
'fastsurfer[lit]',
'fastsurfer[bids]',
'fastsurfer[quicktest]',
]
full = [
Expand Down
Loading