Skip to content
Merged
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
3 changes: 0 additions & 3 deletions docs/data_dictionary.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,5 @@ Produced by the `rbc longitudinal` subcommand group (`template`, `anatomical`, `
| -------------------------------------------------- | ------ | ------------------------------------------------------------------------- | ------------------------------------------ | ---------------------------- |
| `*_space-longitudinal_desc-brain_T1w.nii.gz` | `T1w` | Skull-stripped brain image aligned to the subject's longitudinal template | ANTs registration to longitudinal template | 3D NIfTI |
| `*_space-longitudinal_desc-T1w_mask.nii.gz` | `mask` | Brain mask in longitudinal template space | ANTs registration to longitudinal template | 3D NIfTI, binary mask |
| `*_space-longitudinal_desc-csf_mask.nii.gz` | `mask` | CSF mask in longitudinal template space | ANTs registration to longitudinal template | 3D NIfTI, binary mask |
| `*_space-longitudinal_desc-gm_mask.nii.gz` | `mask` | Gray matter mask in longitudinal template space | ANTs registration to longitudinal template | 3D NIfTI, binary mask |
| `*_space-longitudinal_desc-wm_mask.nii.gz` | `mask` | White matter mask in longitudinal template space | ANTs registration to longitudinal template | 3D NIfTI, binary mask |
| `*_from-T1w_to-longitudinal_mode-image_xfm.nii.gz` | `xfm` | Warp field mapping subject anatomy to the longitudinal template | ANTs registration | 3D NIfTI, displacement field |
| `*_from-longitudinal_to-T1w_mode-image_xfm.nii.gz` | `xfm` | Inverse warp field mapping longitudinal template back to subject anatomy | ANTs registration | 3D NIfTI, displacement field |
18 changes: 0 additions & 18 deletions src/rbc/bids/longitudinal/anatomical.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,6 @@ def resolve_longitudinal_anat(
),
"brain": anat_q.expect(anat_df, suffix=Suffix.T1W, desc="brain"),
"brain_mask": anat_q.find(anat_df, suffix=Suffix.MASK, desc="T1w"),
"csf_mask": anat_q.find(anat_df, suffix=Suffix.MASK, desc="csf"),
"gm_mask": anat_q.find(anat_df, suffix=Suffix.MASK, desc="gm"),
"wm_mask": anat_q.find(anat_df, suffix=Suffix.MASK, desc="wm"),
}


Expand All @@ -71,21 +68,6 @@ def export_longitudinal_anat(aex: Bids, outputs: AnatomicalLongOutputs) -> None:
suffix=Suffix.MASK,
desc="T1w",
)
aex.save(
_require_file(outputs.csf_mask, "csf_mask"),
suffix=Suffix.MASK,
desc="csf",
)
aex.save(
_require_file(outputs.gm_mask, "gm_mask"),
suffix=Suffix.MASK,
desc="gm",
)
aex.save(
_require_file(outputs.wm_mask, "wm_mask"),
suffix=Suffix.MASK,
desc="wm",
)
aex.save(
outputs.long_to_template_xfm,
suffix="xfm",
Expand Down
31 changes: 6 additions & 25 deletions src/rbc/workflows/longitudinal/anatomical.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,12 @@ class AnatomicalLongOutputs(NamedTuple):
brain: Skull-stripped T1w brain warped to longitudinal template space.
brain_mask: Binary brain mask warped to longitudinal template space,
or *None* if not provided.
csf_mask: CSF tissue mask warped to longitudinal template space,
or *None* if not provided.
gm_mask: GM tissue mask warped to longitudinal template space,
or *None* if not provided.
wm_mask: WM tissue mask warped to longitudinal template space,
or *None* if not provided.
long_to_template_xfm: Longitudinal template-to-MNI152 composite warp.
template_to_long_xfm: MNI152-to-longitudinal template composite warp.
"""

brain: Path
brain_mask: Path | None
csf_mask: Path | None
gm_mask: Path | None
wm_mask: Path | None
long_to_template_xfm: Path
template_to_long_xfm: Path

Expand All @@ -52,9 +43,6 @@ def longitudinal_process(
*,
brain: Path,
brain_mask: Path | None = None,
csf_mask: Path | None = None,
gm_mask: Path | None = None,
wm_mask: Path | None = None,
registration_template: Path = REGISTRATION_TEMPLATES.brain_1mm,
) -> AnatomicalLongOutputs:
"""Transform preprocessed anatomical outputs to longitudinal template space.
Expand All @@ -67,35 +55,28 @@ def longitudinal_process(
subj_to_template_xfm: Subject-to-longitudinal-template composite warp.
brain: Preprocessed brain image.
brain_mask: Brain mask, if available.
csf_mask: CSF partial volume mask, if available.
gm_mask: Grey matter partial volume mask, if available.
wm_mask: White matter partial volume mask, if available.
registration_template: Brain template for ANTs registration.

Returns:
:class:`AnatomicalLongOutputs` with all non-null inputs transformed to template
space.
"""

def _xfm(val: Path | None) -> Path | None:
if val is None:
return None
return anat_transform(in_file=val, template=template, xfm=subj_to_template_xfm)

_logger.info("Transforming anatomical outputs to longitudinal template space")
_logger.info("Registration of longitudinal template to standard-space (ANTs)")
transforms = ants_registration(
in_file=template,
registration_template=registration_template,
)
brain_mask_out = (
anat_transform(in_file=brain_mask, template=template, xfm=subj_to_template_xfm)
if brain_mask is not None
else None
)
return AnatomicalLongOutputs(
brain=anat_transform(
in_file=brain, template=template, xfm=subj_to_template_xfm
),
brain_mask=_xfm(brain_mask),
csf_mask=_xfm(csf_mask),
gm_mask=_xfm(gm_mask),
wm_mask=_xfm(wm_mask),
brain_mask=brain_mask_out,
long_to_template_xfm=transforms.anat_to_template,
template_to_long_xfm=transforms.template_to_anat,
)
227 changes: 227 additions & 0 deletions tests/unit/bids/test_longitudinal_anatomical.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
"""Unit tests for ``rbc.bids.longitudinal.anatomical``."""

from __future__ import annotations

from typing import TYPE_CHECKING

import polars as pl

from rbc.bids.longitudinal.anatomical import (
export_longitudinal_anat,
resolve_longitudinal_anat,
)
from rbc.context import RunContext
from rbc.workflows.longitudinal.anatomical import AnatomicalLongOutputs

if TYPE_CHECKING:
from pathlib import Path


def _make_long_outputs(workdir: Path) -> AnatomicalLongOutputs:
"""Build a populated AnatomicalLongOutputs pointing at dummy files."""

def _dummy(name: str) -> Path:
p = workdir / name
p.write_bytes(b"\x00")
return p

return AnatomicalLongOutputs(
brain=_dummy("brain.nii.gz"),
brain_mask=_dummy("brain_mask.nii.gz"),
long_to_template_xfm=_dummy("long_to_tpl.nii.gz"),
template_to_long_xfm=_dummy("tpl_to_long.nii.gz"),
)


class TestExportLongitudinalAnat:
"""Tests for :func:`export_longitudinal_anat`."""

def test_writes_expected_files(self, tmp_path: Path) -> None:
"""Exports brain, brain_mask, and the two xfms under space-longitudinal."""
workdir = tmp_path / "work"
workdir.mkdir()
out_dir = tmp_path / "out"

ctx = RunContext(sub="01", ses="baseline", output_dir=out_dir)
aex = ctx.bids(datatype="anat").derive(space="longitudinal")

export_longitudinal_anat(aex, _make_long_outputs(workdir))

saved = sorted(p.name for p in out_dir.rglob("*.*"))
# Xfm filenames currently carry both ``space-longitudinal`` and the
# ``from-…_to-…`` pair, which is redundant (Jason handover #18 flags
# the ``from-`` workaround more broadly). We assert the current shape
# so regressions are caught, but if that cleanup lands the expected
# xfm names here should drop ``space-longitudinal_``.
assert saved == [
"sub-01_ses-baseline_space-longitudinal_desc-T1w_mask.nii.gz",
"sub-01_ses-baseline_space-longitudinal_desc-brain_T1w.nii.gz",
"sub-01_ses-baseline_space-longitudinal_from-MNI152NLin6Asym"
"_to-longitudinal_mode-image_xfm.nii.gz",
"sub-01_ses-baseline_space-longitudinal_from-longitudinal"
"_to-MNI152NLin6Asym_mode-image_xfm.nii.gz",
]

def test_tissue_masks_not_produced(self, tmp_path: Path) -> None:
"""No csf/gm/wm masks are written under space-longitudinal (#5)."""
workdir = tmp_path / "work"
workdir.mkdir()
out_dir = tmp_path / "out"

ctx = RunContext(sub="01", ses="baseline", output_dir=out_dir)
aex = ctx.bids(datatype="anat").derive(space="longitudinal")

export_longitudinal_anat(aex, _make_long_outputs(workdir))

names = [p.name for p in out_dir.rglob("*.*")]
for tissue in ("csf", "gm", "wm"):
assert not any(f"desc-{tissue}_mask" in n for n in names), (
f"Expected no space-longitudinal {tissue}_mask; got {names}"
)


def _anat_row(
*,
sub: str,
ses: str,
suffix: str,
desc: str | None,
ext: str = ".nii.gz",
extra: list[dict[str, str]] | None = None,
) -> dict[str, object]:
"""Build a single BIDS-like row, including an ``extra_entities`` list."""
desc_part = f"_desc-{desc}" if desc else ""
path = f"sub-{sub}/ses-{ses}/anat/sub-{sub}_ses-{ses}{desc_part}_{suffix}{ext}"
return {
"datatype": "anat",
"suffix": suffix,
"ext": ext,
"sub": sub,
"ses": ses,
"desc": desc,
"root": "/data",
"path": path,
"extra_entities": extra or [],
}


def _df(*rows: dict[str, object]) -> pl.DataFrame:
return pl.DataFrame(list(rows))


class TestResolveLongitudinalAnat:
"""Tests for :func:`resolve_longitudinal_anat`."""

def test_resolves_all_inputs(self, tmp_path: Path) -> None:
"""Happy path: all four keys resolve to the expected paths."""
anat_df = _df(
_anat_row(sub="01", ses="baseline", suffix="T1w", desc="brain"),
_anat_row(sub="01", ses="baseline", suffix="mask", desc="T1w"),
)
tpl_df = _df(
_anat_row(sub="01", ses="longitudinal", suffix="T1w", desc=None),
_anat_row(
sub="01",
ses="longitudinal",
suffix="xfm",
desc=None,
ext=".txt",
extra=[
{"key": "from", "value": "baseline"},
{"key": "to", "value": "longitudinal"},
],
),
)

ctx = RunContext(sub="01", ses="baseline", output_dir=tmp_path)
anat_q = ctx.bids(datatype="anat")
tpl_q = anat_q.derive(ses="longitudinal")

resolved = resolve_longitudinal_anat(
anat_q, tpl_q, anat_df, tpl_df, ses="baseline"
)

assert set(resolved) == {
"template",
"subj_to_template_xfm",
"brain",
"brain_mask",
}
assert str(resolved["template"]).endswith("sub-01_ses-longitudinal_T1w.nii.gz")
assert str(resolved["subj_to_template_xfm"]).endswith(
"sub-01_ses-longitudinal_xfm.txt"
)
assert str(resolved["brain"]).endswith(
"sub-01_ses-baseline_desc-brain_T1w.nii.gz"
)
assert str(resolved["brain_mask"]).endswith(
"sub-01_ses-baseline_desc-T1w_mask.nii.gz"
)

def test_missing_brain_mask_is_none(self, tmp_path: Path) -> None:
"""brain_mask is optional (``.find``) — returns None when absent."""
anat_df = _df(
_anat_row(sub="01", ses="baseline", suffix="T1w", desc="brain"),
)
tpl_df = _df(
_anat_row(sub="01", ses="longitudinal", suffix="T1w", desc=None),
_anat_row(
sub="01",
ses="longitudinal",
suffix="xfm",
desc=None,
ext=".txt",
extra=[
{"key": "from", "value": "baseline"},
{"key": "to", "value": "longitudinal"},
],
),
)

ctx = RunContext(sub="01", ses="baseline", output_dir=tmp_path)
anat_q = ctx.bids(datatype="anat")
tpl_q = anat_q.derive(ses="longitudinal")

resolved = resolve_longitudinal_anat(
anat_q, tpl_q, anat_df, tpl_df, ses="baseline"
)

assert resolved["brain_mask"] is None
assert resolved["brain"] is not None

def test_xfm_query_uses_bids_safe_label(self, tmp_path: Path) -> None:
"""Session labels with non-alphanumerics resolve through ``bids_safe_label``.

``pre-op`` is stored as ``from-preop`` in the xfm entity; the resolver
must sanitize the ses arg before querying or the xfm won't be found.
"""
anat_df = _df(
_anat_row(sub="01", ses="pre-op", suffix="T1w", desc="brain"),
)
tpl_df = _df(
_anat_row(sub="01", ses="longitudinal", suffix="T1w", desc=None),
_anat_row(
sub="01",
ses="longitudinal",
suffix="xfm",
desc=None,
ext=".txt",
extra=[
{"key": "from", "value": "preop"},
{"key": "to", "value": "longitudinal"},
],
),
)

ctx = RunContext(sub="01", ses="pre-op", output_dir=tmp_path)
anat_q = ctx.bids(datatype="anat")
tpl_q = anat_q.derive(ses="longitudinal")

resolved = resolve_longitudinal_anat(
anat_q, tpl_q, anat_df, tpl_df, ses="pre-op"
)

assert resolved["subj_to_template_xfm"] is not None
assert str(resolved["subj_to_template_xfm"]).endswith(
"sub-01_ses-longitudinal_xfm.txt"
)
21 changes: 10 additions & 11 deletions tests/unit/orchestration/test_longitudinal.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from rbc.context import RunContext
from rbc.orchestration import Filters
from rbc.orchestration.longitudinal import process_anat, process_func
from rbc.workflows.longitudinal.anatomical import AnatomicalLongOutputs

_SCHEMA = [
"datatype",
Expand Down Expand Up @@ -48,17 +49,15 @@ def _func_row(sub: str, ses: str, task: str = "rest") -> tuple:
return ("func", "bold", ".nii.gz", sub, ses, None, task, None, None, "/data", path)


def _mock_anat_outputs() -> Mock:
def _mock_anat_outputs() -> AnatomicalLongOutputs:
"""Build a real AnatomicalLongOutputs so typos on missing attrs fail loudly."""
fake = Path("fake_workdir")
m = Mock()
m.brain = fake / "brain.nii.gz"
m.brain_mask = fake / "brain_mask.nii.gz"
m.csf_mask = fake / "csf_mask.nii.gz"
m.gm_mask = fake / "gm_mask.nii.gz"
m.wm_mask = fake / "wm_mask.nii.gz"
m.long_to_template_xfm = fake / "long_to_template_xfm.nii.gz"
m.template_to_long_xfm = fake / "template_to_long_xfm.nii.gz"
return m
return AnatomicalLongOutputs(
brain=fake / "brain.nii.gz",
brain_mask=fake / "brain_mask.nii.gz",
long_to_template_xfm=fake / "long_to_template_xfm.nii.gz",
template_to_long_xfm=fake / "template_to_long_xfm.nii.gz",
)


def _mock_func_outputs(*, with_bold_mask: bool = True) -> Mock:
Expand Down Expand Up @@ -302,7 +301,7 @@ def test_missing_required_output_raises(
pipe_ctx = RunContext(sub="01", ses="baseline", output_dir=tmp_path)
outputs = _mock_anat_outputs()
if side_effect is None:
setattr(outputs, null_field, None)
outputs = outputs._replace(**{null_field: None}) # type: ignore[arg-type]
get_patch = patch(
"rbc.bids.query.find_file",
return_value=Path("fake_workdir/file.nii.gz"),
Expand Down
Loading