From b8318036550f172d9950ad58bff64c0077d4499f Mon Sep 17 00:00:00 2001 From: Karl-Koschutnig Date: Wed, 8 Jul 2026 21:43:32 +0200 Subject: [PATCH 1/3] Add BIDS support with run_fastsurfer_bids.py and related documentation - Introduced run_fastsurfer_bids.py as a BIDS-App entrypoint for FastSurfer. - Added BIDS dataset discovery and processing logic. - Updated README.md to include usage instructions for BIDS. - Created BIDS.md documentation for detailed BIDS entrypoint information. - Added integration tests for BIDS functionality and fetching OpenNeuro dataset. - Included necessary dependencies in pyproject.toml for BIDS support. --- FastSurferCNN/utils/bids.py | 160 +++++++++++++ README.md | 9 + doc/scripts/BIDS.md | 62 +++++ doc/scripts/index.rst | 1 + pyproject.toml | 5 + run_fastsurfer_bids.py | 220 ++++++++++++++++++ test/integration/README.md | 35 +++ .../fetch_openneuro_bids_subset.sh | 82 +++++++ test/integration/test_bids_openneuro.sh | 86 +++++++ test/quicktest/test_bids.py | 95 ++++++++ 10 files changed, 755 insertions(+) create mode 100644 FastSurferCNN/utils/bids.py create mode 100644 doc/scripts/BIDS.md create mode 100755 run_fastsurfer_bids.py create mode 100644 test/integration/README.md create mode 100755 test/integration/fetch_openneuro_bids_subset.sh create mode 100755 test/integration/test_bids_openneuro.sh create mode 100644 test/quicktest/test_bids.py diff --git a/FastSurferCNN/utils/bids.py b/FastSurferCNN/utils/bids.py new file mode 100644 index 000000000..d998ee110 --- /dev/null +++ b/FastSurferCNN/utils/bids.py @@ -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) diff --git a/README.md b/README.md index 9cad50c86..a55dd603b 100644 --- a/README.md +++ b/README.md @@ -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. + Examples -------- diff --git a/doc/scripts/BIDS.md b/doc/scripts/BIDS.md new file mode 100644 index 000000000..4e433efe8 --- /dev/null +++ b/doc/scripts/BIDS.md @@ -0,0 +1,62 @@ +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. + +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. diff --git a/doc/scripts/index.rst b/doc/scripts/index.rst index 5032aba08..bd12dbb8d 100644 --- a/doc/scripts/index.rst +++ b/doc/scripts/index.rst @@ -7,6 +7,7 @@ Scripts RUN_FASTSURFER.md long_fastsurfer.rst BATCH.md + BIDS.md SLURM.md advanced util diff --git a/pyproject.toml b/pyproject.toml index 619de8641..43ecf63e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,9 +70,13 @@ qc = [ lit = [ 'neurolit>=0.6.1', ] +bids = [ + 'pybids>=0.16', +] container = [ 'fastsurfer[qc]', 'fastsurfer[lit]', + 'fastsurfer[bids]', ] doc = [ 'fastsurfer[qc]', @@ -110,6 +114,7 @@ all = [ 'fastsurfer[style]', 'fastsurfer[qc]', 'fastsurfer[lit]', + 'fastsurfer[bids]', 'fastsurfer[quicktest]', ] full = [ diff --git a/run_fastsurfer_bids.py b/run_fastsurfer_bids.py new file mode 100755 index 000000000..424329cae --- /dev/null +++ b/run_fastsurfer_bids.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +# 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. + +""" +BIDS-App entrypoint for FastSurfer. + +Discovers subjects/sessions in a BIDS-valid dataset and delegates processing to the +existing FastSurfer entrypoints (brun_fastsurfer.sh for cross-sectional subjects, +long_fastsurfer.sh for subjects with multiple sessions). +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +FASTSURFER_HOME = Path(__file__).resolve().parent + + +def make_parser() -> argparse.ArgumentParser: + """ + Create the argument parser for run_fastsurfer_bids.py. + + Returns + ------- + argparse.ArgumentParser + The configured parser. + """ + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="Any options after a literal '--' are passed through unchanged to " + "run_fastsurfer.sh/brun_fastsurfer.sh/long_fastsurfer.sh, e.g.:\n" + " run_fastsurfer_bids.py /bids /out participant -- --parallel --3T", + ) + parser.add_argument("bids_dir", type=Path, help="Path to the BIDS-valid input dataset.") + parser.add_argument( + "output_dir", type=Path, + help="Output directory (used as FastSurfer's SUBJECTS_DIR).", + ) + parser.add_argument( + "analysis_level", choices=["participant", "group"], + help="Level of analysis. Only 'participant' performs processing; 'group' is a no-op.", + ) + parser.add_argument( + "--participant_label", "--participant-label", dest="participant_label", nargs="+", default=None, + metavar="LABEL", + help="Restrict processing to these participant labels (with or without 'sub-' prefix). " + "Default: process all subjects found.", + ) + parser.add_argument( + "--session_label", "--session-label", dest="session_label", nargs="+", default=None, + metavar="LABEL", + help="Restrict processing to these session labels (with or without 'ses-' prefix). " + "Default: process all sessions found.", + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--longitudinal", action="store_true", + help="Force longitudinal processing for all selected subjects (requires >=2 sessions each).", + ) + mode.add_argument( + "--cross_sectional", action="store_true", + help="Force cross-sectional processing of every session independently, even for subjects " + "with multiple sessions (default: subjects with >=2 sessions are processed longitudinally).", + ) + parser.add_argument( + "--skip_bids_validator", action="store_true", + help="Skip validation of the input dataset against the BIDS specification.", + ) + parser.add_argument( + "--fs_license", type=Path, default=None, + help="Path to the FreeSurfer license file (passed through to run_fastsurfer.sh).", + ) + parser.add_argument( + "--dry_run", action="store_true", + help="Print the commands that would be run, without executing them.", + ) + return parser + + +def _split_passthrough(argv: list[str]) -> tuple[list[str], list[str]]: + if "--" in argv: + idx = argv.index("--") + return argv[:idx], argv[idx + 1:] + return argv, [] + + +def _write_subject_list(path: Path, lines: list[str]) -> None: + with open(path, "w") as f: + f.write("\n".join(lines) + "\n") + + +def _run(cmd: list[str], dry_run: bool) -> None: + print("+ " + " ".join(str(c) for c in cmd)) + if dry_run: + return + subprocess.run(cmd, check=True) + + +def main(argv: list[str] | None = None) -> int: + """ + Run the BIDS-App entrypoint. + + Parameters + ---------- + argv : list[str], optional + Argument vector (defaults to sys.argv[1:]). + + Returns + ------- + int + Process exit code. + """ + argv = sys.argv[1:] if argv is None else argv + own_args, passthrough = _split_passthrough(argv) + args = make_parser().parse_args(own_args) + + if args.analysis_level == "group": + print("analysis_level 'group' is a no-op for FastSurfer; nothing to do.") + return 0 + + from FastSurferCNN.utils import bids + from FastSurferCNN.version import read_and_close_version + + bids_dir: Path = args.bids_dir.resolve() + output_dir: Path = args.output_dir.resolve() + + subjects = bids.find_subjects( + bids_dir, + participant_labels=args.participant_label, + session_labels=args.session_label, + validate=not args.skip_bids_validator, + ) + if not subjects: + print(f"ERROR: No subjects with a T1w image found in {bids_dir}.", file=sys.stderr) + return 1 + + if not args.dry_run: + bids.write_derivatives_dataset_description(output_dir, read_and_close_version()) + + common_args = list(passthrough) + if args.fs_license is not None: + common_args += ["--fs_license", str(args.fs_license)] + + cross_sectional_lines = [] + for subject in subjects: + force_long = args.longitudinal + force_cross = args.cross_sectional + is_long = subject.is_longitudinal and not force_cross + if force_long and not subject.is_longitudinal: + print( + f"WARNING: --longitudinal requested but {subject.subject_id} only has a single " + "session, processing cross-sectionally instead.", + file=sys.stderr, + ) + is_long = False + elif force_long: + is_long = True + + if is_long: + tpids = [subject.output_id(s) for s in subject.sessions] + t1s = [str(s.t1w) for s in subject.sessions] + if any(s.t2w is not None for s in subject.sessions): + print( + f"WARNING: {subject.subject_id} has T2w images, but T2w/HypVINN is not supported " + "in the longitudinal pipeline; ignoring T2w for this subject.", + file=sys.stderr, + ) + cmd = [ + str(FASTSURFER_HOME / "long_fastsurfer.sh"), + "--tid", subject.subject_id, + "--tpids", *tpids, + "--t1s", *t1s, + "--sd", str(output_dir), + *common_args, + ] + _run(cmd, args.dry_run) + else: + for session in subject.sessions: + sid = subject.output_id(session) + line = f"{sid}={session.t1w}" + if session.t2w is not None: + line += f" --t2 {session.t2w}" + cross_sectional_lines.append(line) + + if cross_sectional_lines: + subject_list_path = output_dir / "scripts" / "bids_subjects.txt" + if not args.dry_run: + subject_list_path.parent.mkdir(parents=True, exist_ok=True) + _write_subject_list(subject_list_path, cross_sectional_lines) + else: + print(f"+ (dry run) would write subject list to {subject_list_path}:") + for line in cross_sectional_lines: + print(f" {line}") + cmd = [ + str(FASTSURFER_HOME / "brun_fastsurfer.sh"), + "--subject_list", str(subject_list_path), + "--sd", str(output_dir), + *common_args, + ] + _run(cmd, args.dry_run) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/integration/README.md b/test/integration/README.md new file mode 100644 index 000000000..5bc98ffd3 --- /dev/null +++ b/test/integration/README.md @@ -0,0 +1,35 @@ +Integration tests +================== + +Unlike `test/quicktest` (which compares two already-processed subject directories) and +`test/image` (which needs pre-existing reference images), the tests here fetch real data over the +network and drive FastSurfer's entrypoints end-to-end. + +BIDS: `test_bids_openneuro.sh` +------------------------------- +Smoke test for `run_fastsurfer_bids.py`. Downloads a small subset of the public OpenNeuro dataset +[ds004937](https://doi.org/10.18112/openneuro.ds004937.v1.0.1) (no login/token required, ~60MB) via +`fetch_openneuro_bids_subset.sh`, then runs `run_fastsurfer_bids.py` once over two subjects to +exercise both routing paths in a single call: + +- one subject with only `ses-1` fetched -> cross-sectional path (`brun_fastsurfer.sh`) +- one subject with `ses-1..ses-4` fetched -> longitudinal path (`long_fastsurfer.sh`) + +Two subjects with different session counts are required because a single subject cannot exercise +both code paths at once, and this also verifies that a mixed batch is routed correctly (not just +each path in isolation). + +```bash +# print the commands that would run, without executing FastSurfer (default) +test/integration/test_bids_openneuro.sh /tmp/bids_smoketest + +# actually run FastSurfer (requires fastsurfer[all], a FreeSurfer license, and +# enough time/compute -- the longitudinal subject processes 4 timepoints) +test/integration/test_bids_openneuro.sh /tmp/bids_smoketest --run --fs_license /path/to/license.txt + +# actually run, segmentation only (much faster, skips surface reconstruction) +test/integration/test_bids_openneuro.sh /tmp/bids_smoketest --run --fs_license /path/to/license.txt -- --seg_only +``` + +Requires `fastsurfer[bids]` (pybids) to be installed. Override the default test subjects via the +`CROSS_SUB`/`LONG_SUB`/`LONG_SESSIONS` environment variables if needed. diff --git a/test/integration/fetch_openneuro_bids_subset.sh b/test/integration/fetch_openneuro_bids_subset.sh new file mode 100755 index 000000000..827fafe9d --- /dev/null +++ b/test/integration/fetch_openneuro_bids_subset.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# +# 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. +# +# Fetches a small subset of subjects/sessions from the public OpenNeuro dataset +# ds004937 (https://doi.org/10.18112/openneuro.ds004937.v1.0.1) to assemble a +# minimal, valid BIDS dataset for testing run_fastsurfer_bids.py. +# +# The dataset has no T2w images and every subject has 4 anat sessions +# (ses-1..ses-4, all T1w, acq-mprage). To exercise both the cross-sectional and +# the longitudinal code path of run_fastsurfer_bids.py against real data with a +# single download, this script fetches: +# - CROSS_SUB (default: sub-119BPAF161002): only ses-1 -> single session +# - LONG_SUB (default: sub-119BPAF161001): ses-1..ses-4 -> multi session +# +# Usage: fetch_openneuro_bids_subset.sh +# +# Env overrides: OPENNEURO_ACCESSION, CROSS_SUB, LONG_SUB, LONG_SESSIONS + +set -euo pipefail + +target="${1:?Usage: fetch_openneuro_bids_subset.sh }" + +ACCESSION="${OPENNEURO_ACCESSION:-ds004937}" +CROSS_SUB="${CROSS_SUB:-sub-119BPAF161002}" +LONG_SUB="${LONG_SUB:-sub-119BPAF161001}" +LONG_SESSIONS="${LONG_SESSIONS:-ses-1 ses-2 ses-3 ses-4}" +BASE_URL="https://s3.amazonaws.com/openneuro.org/${ACCESSION}" + +mkdir -p "$target" + +download() { + # 1: key relative to dataset root, e.g. sub-X/ses-1/anat/sub-X_ses-1_acq-mprage_T1w.nii.gz + local key="$1" + local dest="$target/$key" + mkdir -p "$(dirname "$dest")" + if [[ -s "$dest" ]]; then + echo " already present: $key" + return + fi + echo " fetching: $key" + curl -sSfL "$BASE_URL/$key" -o "$dest" +} + +echo "Fetching dataset-level BIDS files for $ACCESSION..." +download "dataset_description.json" +download "participants.tsv" +download "participants.json" +download "README" +download "CHANGES" + +download_session_anat() { + # 1: subject id (sub-X), 2: session id (ses-Y) + local sub="$1" ses="$2" + local t1_base="${sub}_${ses}_acq-mprage_T1w" + download "$sub/$ses/anat/${t1_base}.nii.gz" + download "$sub/$ses/anat/${t1_base}.json" +} + +echo "Fetching cross-sectional subject $CROSS_SUB (single session, ses-1)..." +download_session_anat "$CROSS_SUB" "ses-1" + +echo "Fetching longitudinal subject $LONG_SUB (sessions: $LONG_SESSIONS)..." +for ses in $LONG_SESSIONS; do + download_session_anat "$LONG_SUB" "$ses" +done + +echo "Done. BIDS subset written to $target" +echo " Cross-sectional test subject: $CROSS_SUB (ses-1 only)" +echo " Longitudinal test subject: $LONG_SUB ($LONG_SESSIONS)" diff --git a/test/integration/test_bids_openneuro.sh b/test/integration/test_bids_openneuro.sh new file mode 100755 index 000000000..63ab65000 --- /dev/null +++ b/test/integration/test_bids_openneuro.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# +# 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. +# +# End-to-end smoke test for run_fastsurfer_bids.py against a small, real subset +# of the OpenNeuro dataset ds004937 (https://doi.org/10.18112/openneuro.ds004937.v1.0.1). +# +# Downloads one single-session subject (-> exercises the cross-sectional / +# brun_fastsurfer.sh path) and one four-session subject (-> exercises the +# longitudinal / long_fastsurfer.sh path) into a local BIDS dataset, then runs +# run_fastsurfer_bids.py on both subjects in a single call, relying on its +# auto-detection to route each subject to the correct pipeline. +# +# By default this only prints the commands that would run (--dry_run). Pass +# --run --fs_license to actually execute FastSurfer (requires the full +# fastsurfer[all] Python environment, a FreeSurfer license, and a GPU/CPU with +# enough time -- the longitudinal subject alone processes 4 timepoints). +# +# Usage: +# test_bids_openneuro.sh [--run --fs_license ] [-- ] + +set -euo pipefail + +if [[ -z "${FASTSURFER_HOME:-}" ]]; then + FASTSURFER_HOME=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." &> /dev/null && pwd) +fi + +work_dir="${1:?Usage: test_bids_openneuro.sh [--run --fs_license ] [-- ]}" +shift || true + +dry_run="--dry_run" +fs_license=() +extra_flags=() +while [[ $# -gt 0 ]]; do + case "$1" in + --run) dry_run="" ; shift ;; + --fs_license) fs_license=(--fs_license "$2") ; shift 2 ;; + --) shift ; extra_flags=("$@") ; break ;; + *) echo "ERROR: Unknown option $1" ; exit 1 ;; + esac +done + +if [[ -z "$dry_run" && ${#fs_license[@]} -eq 0 ]]; then + echo "ERROR: --run requires --fs_license ." + exit 1 +fi + +bids_dir="$work_dir/bids" +output_dir="$work_dir/output" + +echo "=== Step 1/2: Fetching OpenNeuro BIDS subset into $bids_dir ===" +"$(dirname "${BASH_SOURCE[0]}")/fetch_openneuro_bids_subset.sh" "$bids_dir" + +echo +echo "=== Step 2/2: Running run_fastsurfer_bids.py (participant level) ===" +cross_sub="${CROSS_SUB:-sub-119BPAF161002}" +long_sub="${LONG_SUB:-sub-119BPAF161001}" + +cmd=("$FASTSURFER_HOME/run_fastsurfer_bids.py" + "$bids_dir" "$output_dir" participant + --participant_label "$cross_sub" "$long_sub" + --skip_bids_validator + $dry_run + "${fs_license[@]}") +if [[ ${#extra_flags[@]} -gt 0 ]]; then + cmd+=(-- "${extra_flags[@]}") +fi + +echo "+ ${cmd[*]}" +"${cmd[@]}" + +echo +echo "Expected: $cross_sub (ses-1 only) routed through brun_fastsurfer.sh," +echo " $long_sub (ses-1..ses-4) routed through long_fastsurfer.sh." diff --git a/test/quicktest/test_bids.py b/test/quicktest/test_bids.py new file mode 100644 index 000000000..e95786a09 --- /dev/null +++ b/test/quicktest/test_bids.py @@ -0,0 +1,95 @@ +import json +from pathlib import Path + +import pytest + +pytest.importorskip("bids", reason="pybids (fastsurfer[bids] extra) is not installed") + +from FastSurferCNN.utils import bids # noqa: E402 + + +def _touch(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + + +@pytest.fixture +def bids_dataset(tmp_path: Path) -> Path: + root = tmp_path / "bids" + _touch(root / "dataset_description.json") + (root / "dataset_description.json").write_text( + json.dumps({"Name": "Test Dataset", "BIDSVersion": "1.8.0"}) + ) + + # sub-01: single session (no ses- level), T1w only + _touch(root / "sub-01" / "anat" / "sub-01_T1w.nii.gz") + + # sub-02: two sessions -> longitudinal; ses-01 has a T2w, ses-02 does not + _touch(root / "sub-02" / "ses-01" / "anat" / "sub-02_ses-01_T1w.nii.gz") + _touch(root / "sub-02" / "ses-01" / "anat" / "sub-02_ses-01_T2w.nii.gz") + _touch(root / "sub-02" / "ses-02" / "anat" / "sub-02_ses-02_T1w.nii.gz") + + # sub-03: only a T2w, no T1w anywhere -> must be skipped + _touch(root / "sub-03" / "anat" / "sub-03_T2w.nii.gz") + + return root + + +def test_find_subjects_basic_discovery(bids_dataset: Path): + subjects = bids.find_subjects(bids_dataset, validate=False) + by_id = {s.subject_id: s for s in subjects} + + # sub-03 has no T1w and must be excluded + assert set(by_id) == {"sub-01", "sub-02"} + + sub01 = by_id["sub-01"] + assert not sub01.is_longitudinal + assert len(sub01.sessions) == 1 + assert sub01.sessions[0].session_id is None + assert sub01.output_id(sub01.sessions[0]) == "sub-01" + assert sub01.sessions[0].t1w.name == "sub-01_T1w.nii.gz" + assert sub01.sessions[0].t2w is None + + sub02 = by_id["sub-02"] + assert sub02.is_longitudinal + assert len(sub02.sessions) == 2 + sessions_by_id = {s.session_id: s for s in sub02.sessions} + assert set(sessions_by_id) == {"01", "02"} + assert sub02.output_id(sessions_by_id["01"]) == "sub-02_ses-01" + assert sessions_by_id["01"].t2w is not None + assert sessions_by_id["02"].t2w is None + + +def test_find_subjects_participant_filter(bids_dataset: Path): + subjects = bids.find_subjects(bids_dataset, participant_labels=["01"], validate=False) + assert [s.subject_id for s in subjects] == ["sub-01"] + + # accepts labels with or without the sub- prefix + subjects = bids.find_subjects(bids_dataset, participant_labels=["sub-01"], validate=False) + assert [s.subject_id for s in subjects] == ["sub-01"] + + +def test_find_subjects_unknown_participant_raises(bids_dataset: Path): + with pytest.raises(ValueError, match="99"): + bids.find_subjects(bids_dataset, participant_labels=["99"], validate=False) + + +def test_find_subjects_session_filter(bids_dataset: Path): + subjects = bids.find_subjects( + bids_dataset, participant_labels=["02"], session_labels=["01"], validate=False, + ) + assert len(subjects) == 1 + assert not subjects[0].is_longitudinal + assert subjects[0].sessions[0].session_id == "01" + + +def test_write_derivatives_dataset_description(tmp_path: Path): + out_dir = tmp_path / "output" + bids.write_derivatives_dataset_description(out_dir, "2.6.0-dev0") + + description_file = out_dir / "dataset_description.json" + assert description_file.exists() + description = json.loads(description_file.read_text()) + assert description["DatasetType"] == "derivative" + assert description["GeneratedBy"][0]["Name"] == "FastSurfer" + assert description["GeneratedBy"][0]["Version"] == "2.6.0-dev0" From a7962305b5b8cd0e7ba4b6f44f6b3e2d20512caa Mon Sep 17 00:00:00 2001 From: Karl Koschutnig Date: Thu, 9 Jul 2026 09:42:52 +0200 Subject: [PATCH 2/3] Enhance BIDS support with new checks and documentation updates - Added a developer-oriented smoke test for the BIDS add-on. - Updated `test_bids_openneuro.sh` to include new usage options. - Introduced `_requires_cross_sectional_passthrough` function to handle passthrough flags. - Added tests for the new passthrough functionality. - Improved documentation in BIDS.md and README.md for clarity on new options. --- CorpusCallosum/shape/subsegment_contour.py | 4 +- CorpusCallosum/utils/mapping_helpers.py | 4 +- doc/scripts/BIDS.md | 2 + run_fastsurfer_bids.py | 23 ++++- test/integration/README.md | 25 ++++- test/integration/check_bids_addon.sh | 101 +++++++++++++++++++++ test/integration/test_bids_openneuro.sh | 65 +++++++++++-- test/quicktest/test_run_fastsurfer_bids.py | 15 +++ test/test_cc_numpy_compat.py | 23 +++++ 9 files changed, 246 insertions(+), 16 deletions(-) create mode 100644 test/integration/check_bids_addon.sh create mode 100644 test/quicktest/test_run_fastsurfer_bids.py create mode 100644 test/test_cc_numpy_compat.py diff --git a/CorpusCallosum/shape/subsegment_contour.py b/CorpusCallosum/shape/subsegment_contour.py index 2434081df..88326c0da 100644 --- a/CorpusCallosum/shape/subsegment_contour.py +++ b/CorpusCallosum/shape/subsegment_contour.py @@ -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 diff --git a/CorpusCallosum/utils/mapping_helpers.py b/CorpusCallosum/utils/mapping_helpers.py index 666e5e08b..c043cc542 100644 --- a/CorpusCallosum/utils/mapping_helpers.py +++ b/CorpusCallosum/utils/mapping_helpers.py @@ -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 diff --git a/doc/scripts/BIDS.md b/doc/scripts/BIDS.md index 4e433efe8..7e5bb0823 100644 --- a/doc/scripts/BIDS.md +++ b/doc/scripts/BIDS.md @@ -43,6 +43,8 @@ Session and longitudinal handling 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 ------------------ diff --git a/run_fastsurfer_bids.py b/run_fastsurfer_bids.py index 424329cae..450e68e8b 100755 --- a/run_fastsurfer_bids.py +++ b/run_fastsurfer_bids.py @@ -98,6 +98,12 @@ def _split_passthrough(argv: list[str]) -> tuple[list[str], list[str]]: return argv, [] +def _requires_cross_sectional_passthrough(passthrough: list[str]) -> bool: + """Return True if passthrough flags are incompatible with long_fastsurfer.sh.""" + unsupported_for_longitudinal = {"--seg_only", "--surf_only"} + return any(flag in unsupported_for_longitudinal for flag in passthrough) + + def _write_subject_list(path: Path, lines: list[str]) -> None: with open(path, "w") as f: f.write("\n".join(lines) + "\n") @@ -152,13 +158,14 @@ def main(argv: list[str] | None = None) -> int: bids.write_derivatives_dataset_description(output_dir, read_and_close_version()) common_args = list(passthrough) + requires_cross_sectional = _requires_cross_sectional_passthrough(common_args) if args.fs_license is not None: common_args += ["--fs_license", str(args.fs_license)] cross_sectional_lines = [] for subject in subjects: force_long = args.longitudinal - force_cross = args.cross_sectional + force_cross = args.cross_sectional or requires_cross_sectional is_long = subject.is_longitudinal and not force_cross if force_long and not subject.is_longitudinal: print( @@ -167,6 +174,20 @@ def main(argv: list[str] | None = None) -> int: file=sys.stderr, ) is_long = False + elif force_long and requires_cross_sectional: + print( + f"WARNING: --longitudinal requested for {subject.subject_id}, but the passthrough " + "options require cross-sectional processing; processing sessions independently instead.", + file=sys.stderr, + ) + is_long = False + elif subject.is_longitudinal and requires_cross_sectional: + print( + f"WARNING: {subject.subject_id} has multiple sessions, but the passthrough options " + "are not supported by the longitudinal pipeline; processing sessions independently instead.", + file=sys.stderr, + ) + is_long = False elif force_long: is_long = True diff --git a/test/integration/README.md b/test/integration/README.md index 5bc98ffd3..16f1a95bc 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -27,9 +27,30 @@ test/integration/test_bids_openneuro.sh /tmp/bids_smoketest # enough time/compute -- the longitudinal subject processes 4 timepoints) test/integration/test_bids_openneuro.sh /tmp/bids_smoketest --run --fs_license /path/to/license.txt -# actually run, segmentation only (much faster, skips surface reconstruction) -test/integration/test_bids_openneuro.sh /tmp/bids_smoketest --run --fs_license /path/to/license.txt -- --seg_only +# actually run, segmentation only (much faster, skips surface reconstruction; +# no FreeSurfer license needed) +test/integration/test_bids_openneuro.sh /tmp/bids_smoketest --run -- --seg_only ``` +The first positional argument is the FastSurfer output directory. By default the BIDS test dataset +is downloaded next to it as `_bids`; override that with `--bids_dir ` if needed. + Requires `fastsurfer[bids]` (pybids) to be installed. Override the default test subjects via the `CROSS_SUB`/`LONG_SUB`/`LONG_SESSIONS` environment variables if needed. + +Developer shortcut: `check_bids_addon.sh` +----------------------------------------- +For contributors working on the BIDS add-on, this wrapper runs the two most useful checks together: + +- targeted pytest coverage for BIDS discovery and routing +- the OpenNeuro-based dry-run smoke test in `--seg_only` mode + +```bash +# use an existing virtual environment, then run both smoke checks +test/integration/check_bids_addon.sh --venv .venv + +# or choose a custom work directory for downloaded test data and dry-run output +test/integration/check_bids_addon.sh --venv .venv --work_dir /data/local/tmp_big +``` + +This is intended as the quickest developer-facing validation path before trying a full longitudinal run. diff --git a/test/integration/check_bids_addon.sh b/test/integration/check_bids_addon.sh new file mode 100644 index 000000000..85a31534c --- /dev/null +++ b/test/integration/check_bids_addon.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +# 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. + +# Developer-oriented smoke test for the BIDS add-on. +# +# This wrapper bundles the lightweight checks needed to validate the BIDS support: +# 1. targeted pytest coverage for the BIDS discovery/routing code +# 2. OpenNeuro-based dry run against one cross-sectional and one longitudinal subject +# +# By default it only performs dry-run checks and does not execute FastSurfer. + +set -euo pipefail + +if [[ -z "${FASTSURFER_HOME:-}" ]]; then + FASTSURFER_HOME=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." &> /dev/null && pwd) +fi + +usage() { + cat <] [--work_dir ] [--venv ] [--skip_pytest] [--skip_dry_run] + +Runs the developer smoke checks for the BIDS add-on: + - pytest: test/quicktest/test_bids.py and test/quicktest/test_run_fastsurfer_bids.py + - dry run: test/integration/test_bids_openneuro.sh with --seg_only + +Options: + --python Python executable to use. Default: python + --work_dir Output directory for the integration dry-run. + Default: /tmp/fastsurfer_bids_smoketest + --venv Activate this virtual environment before running checks. + --skip_pytest Skip the targeted pytest step. + --skip_dry_run Skip the OpenNeuro dry-run step. + -h, --help Show this help. + +Examples: + test/integration/check_bids_addon.sh --venv .venv + test/integration/check_bids_addon.sh --venv .venv --work_dir /data/local/tmp_big +EOF +} + +python_cmd="python" +work_dir="/tmp/fastsurfer_bids_smoketest" +venv_dir="" +skip_pytest="false" +skip_dry_run="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --python) python_cmd="$2" ; shift 2 ;; + --work_dir) work_dir="$2" ; shift 2 ;; + --venv) venv_dir="$2" ; shift 2 ;; + --skip_pytest) skip_pytest="true" ; shift ;; + --skip_dry_run) skip_dry_run="true" ; shift ;; + -h|--help) usage ; exit 0 ;; + *) echo "ERROR: Unknown option $1" ; usage ; exit 1 ;; + esac +done + +if [[ -n "$venv_dir" ]]; then + # shellcheck disable=SC1090 + source "$venv_dir/bin/activate" +fi + +cd "$FASTSURFER_HOME" + +if [[ "$skip_pytest" != "true" ]]; then + ref_dir="$work_dir/pytest_ref" + subjects_dir="$work_dir/pytest_subjects" + mkdir -p "$ref_dir/sub-dummy" "$subjects_dir" + echo "=== Step 1/2: Targeted BIDS pytest checks ===" + REF_DIR="$ref_dir" SUBJECTS_DIR="$subjects_dir" \ + "$python_cmd" -m pytest \ + test/quicktest/test_bids.py \ + test/quicktest/test_run_fastsurfer_bids.py -q +fi + +if [[ "$skip_dry_run" != "true" ]]; then + echo + echo "=== Step 2/2: OpenNeuro BIDS dry-run smoke test ===" + bash test/integration/test_bids_openneuro.sh \ + "$work_dir/output" \ + --bids_dir "$work_dir/bids" \ + -- --seg_only +fi + +echo +echo "BIDS add-on smoke checks completed successfully." \ No newline at end of file diff --git a/test/integration/test_bids_openneuro.sh b/test/integration/test_bids_openneuro.sh index 63ab65000..7b5333565 100755 --- a/test/integration/test_bids_openneuro.sh +++ b/test/integration/test_bids_openneuro.sh @@ -29,7 +29,8 @@ # enough time -- the longitudinal subject alone processes 4 timepoints). # # Usage: -# test_bids_openneuro.sh [--run --fs_license ] [-- ] +# test_bids_openneuro.sh [--bids_dir ] [--run] [--fs_license ] \ +# [-- ] set -euo pipefail @@ -37,29 +38,70 @@ if [[ -z "${FASTSURFER_HOME:-}" ]]; then FASTSURFER_HOME=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." &> /dev/null && pwd) fi -work_dir="${1:?Usage: test_bids_openneuro.sh [--run --fs_license ] [-- ]}" +usage() { + cat < [--bids_dir ] [--run] [--fs_license ] \ + [-- ] + +Downloads a minimal OpenNeuro BIDS subset with: + - one single-session subject for cross-sectional processing + - one multi-session subject for longitudinal processing + +Arguments: + FastSurfer output directory (used as SUBJECTS_DIR). + +Options: + --bids_dir Where to download the BIDS input dataset. + Default: sibling path named _bids + --run Execute FastSurfer instead of only printing commands. + --fs_license FreeSurfer license file. Required for full runs, optional for --seg_only. + -- Pass remaining flags through to run_fastsurfer_bids.py. + +Examples: + test_bids_openneuro.sh /data/fs_out + test_bids_openneuro.sh /data/fs_out -- --seg_only --parallel 2 + test_bids_openneuro.sh /data/fs_out --run --fs_license /data/license.txt -- --parallel 2 +EOF +} + +if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then + usage + exit 0 +fi + +output_dir="${1:?Usage: test_bids_openneuro.sh [--bids_dir ] [--run] [--fs_license ] [-- ] }" shift || true dry_run="--dry_run" fs_license=() extra_flags=() +default_bids_dir="$(dirname "$output_dir")/$(basename "$output_dir")_bids" +bids_dir="$default_bids_dir" while [[ $# -gt 0 ]]; do case "$1" in + --bids_dir) bids_dir="$2" ; shift 2 ;; --run) dry_run="" ; shift ;; --fs_license) fs_license=(--fs_license "$2") ; shift 2 ;; + -h|--help) usage ; exit 0 ;; --) shift ; extra_flags=("$@") ; break ;; *) echo "ERROR: Unknown option $1" ; exit 1 ;; esac done -if [[ -z "$dry_run" && ${#fs_license[@]} -eq 0 ]]; then - echo "ERROR: --run requires --fs_license ." +needs_license="true" +for flag in "${extra_flags[@]}"; do + if [[ "$flag" == "--seg_only" ]]; then + needs_license="false" + break + fi +done + +if [[ -z "$dry_run" && ${#fs_license[@]} -eq 0 && "$needs_license" == "true" ]]; then + echo "ERROR: --run without --seg_only requires --fs_license ." exit 1 fi -bids_dir="$work_dir/bids" -output_dir="$work_dir/output" - echo "=== Step 1/2: Fetching OpenNeuro BIDS subset into $bids_dir ===" "$(dirname "${BASH_SOURCE[0]}")/fetch_openneuro_bids_subset.sh" "$bids_dir" @@ -82,5 +124,10 @@ echo "+ ${cmd[*]}" "${cmd[@]}" echo -echo "Expected: $cross_sub (ses-1 only) routed through brun_fastsurfer.sh," -echo " $long_sub (ses-1..ses-4) routed through long_fastsurfer.sh." +if printf '%s\n' "${extra_flags[@]}" | grep -qx -- '--seg_only\|--surf_only'; then + echo "Expected: both $cross_sub and $long_sub routed through brun_fastsurfer.sh," + echo " because segmentation-only and surface-only modes are cross-sectional only." +else + echo "Expected: $cross_sub (ses-1 only) routed through brun_fastsurfer.sh," + echo " $long_sub (ses-1..ses-4) routed through long_fastsurfer.sh." +fi diff --git a/test/quicktest/test_run_fastsurfer_bids.py b/test/quicktest/test_run_fastsurfer_bids.py new file mode 100644 index 000000000..4302fea22 --- /dev/null +++ b/test/quicktest/test_run_fastsurfer_bids.py @@ -0,0 +1,15 @@ +from pathlib import Path + +from run_fastsurfer_bids import _requires_cross_sectional_passthrough + + +def test_requires_cross_sectional_passthrough_detects_seg_only(): + assert _requires_cross_sectional_passthrough(["--seg_only"]) + + +def test_requires_cross_sectional_passthrough_detects_surf_only(): + assert _requires_cross_sectional_passthrough(["--surf_only", "--parallel", "2"]) + + +def test_requires_cross_sectional_passthrough_allows_full_pipeline_flags(): + assert not _requires_cross_sectional_passthrough(["--parallel", "2", "--3T"]) \ No newline at end of file diff --git a/test/test_cc_numpy_compat.py b/test/test_cc_numpy_compat.py new file mode 100644 index 000000000..655db1353 --- /dev/null +++ b/test/test_cc_numpy_compat.py @@ -0,0 +1,23 @@ +import numpy as np + +from CorpusCallosum.shape.subsegment_contour import transform_to_acpc_standard +from CorpusCallosum.utils.mapping_helpers import correct_nodding + + +def test_transform_to_acpc_standard_accepts_2d_vectors_with_numpy2(): + contour = np.array([[0.0, 1.0, 2.0], [0.0, 0.5, 0.0]]) + ac_pt = np.array([0.0, 0.0]) + pc_pt = np.array([1.0, 0.0]) + + contour_acpc, ac_pt_acpc, pc_pt_acpc, rotate_back = transform_to_acpc_standard(contour, ac_pt, pc_pt) + + assert contour_acpc.shape == contour.shape + assert np.allclose(ac_pt_acpc, [0.0, 0.0]) + assert pc_pt_acpc.shape == (2,) + assert rotate_back(contour_acpc).shape == contour.shape + + +def test_acpc_rotation_matrix_accepts_2d_vectors_with_numpy2(): + rotation = correct_nodding(np.array([0.0, 0.0]), np.array([1.0, 0.0])) + + assert rotation.shape == (3, 3) \ No newline at end of file From 14cbb103a14ee273a38f2d60c1d8945362cdc81d Mon Sep 17 00:00:00 2001 From: Karl Koschutnig Date: Wed, 15 Jul 2026 10:57:26 +0200 Subject: [PATCH 3/3] Remove unused Path import flagged by ruff Co-Authored-By: Claude Sonnet 5 --- test/quicktest/test_run_fastsurfer_bids.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/quicktest/test_run_fastsurfer_bids.py b/test/quicktest/test_run_fastsurfer_bids.py index 4302fea22..6d7585c07 100644 --- a/test/quicktest/test_run_fastsurfer_bids.py +++ b/test/quicktest/test_run_fastsurfer_bids.py @@ -1,5 +1,3 @@ -from pathlib import Path - from run_fastsurfer_bids import _requires_cross_sectional_passthrough