Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f850274
Add PAM50 normalization option (a new arg '-normalize-PAM50') to `sct…
valosekj Apr 27, 2026
ddad85d
Add fall back to all available levels when fewer than 3 levels are pr…
valosekj Apr 27, 2026
02a053f
Add tests
valosekj Apr 27, 2026
66c5895
Merge branch 'master' into jv/sct_extract_metric_normalize_pam50
valosekj Jun 3, 2026
587f354
improve comments and docstring
valosekj Jun 3, 2026
385b763
add some extra comments to improve code readability
valosekj Jun 3, 2026
cd11024
clarify fallback section to all available levels
valosekj Jun 3, 2026
cdad227
Merge branch 'master' into jv/sct_extract_metric_normalize_pam50
valosekj Jun 8, 2026
e08b6c8
Merge branch 'master' into jv/sct_extract_metric_normalize_pam50
joshuacwnewton Jun 11, 2026
d7c8937
Merge branch 'master' into jv/sct_extract_metric_normalize_pam50
valosekj Jun 22, 2026
bd03cd8
Merge branch 'master' into jv/sct_extract_metric_normalize_pam50
valosekj Jul 9, 2026
978d34c
Merge branch 'master' into jv/sct_extract_metric_normalize_pam50
valosekj Jul 21, 2026
b518834
Merge remote-tracking branch 'origin/master' into jv/sct_extract_metr…
valosekj Aug 11, 2026
bbf7755
Simplify `-normalize-PAM50` to a single `extract_metric()` call
valosekj Aug 11, 2026
47fafc4
Remove obsolete `sct_testing` marker
valosekj Aug 11, 2026
a564810
Improve test_cli_sct_extract_metric
valosekj Aug 11, 2026
2ad22cd
Document extract_metric-form vs compute_shape-form conversion
valosekj Aug 11, 2026
42a3461
Explain that 0 and values >=49 are reserved/non-vertebral-level labels
valosekj Aug 11, 2026
7f04548
Move `_build_pam50_agg_metric` from `sct_extract_metric` to `metrics_…
valosekj Aug 11, 2026
6b27442
Clarify `-z` arg help text
valosekj Aug 12, 2026
669672d
Merge branch 'master' into jv/sct_extract_metric_normalize_pam50
valosekj Aug 12, 2026
dba8c25
Update docstring of `interpolate_metrics`
valosekj Aug 13, 2026
d51afda
Merge branch 'master' into jv/sct_extract_metric_normalize_pam50
valosekj Aug 13, 2026
14d0849
Simplify scale_mean computation in interpolate_metrics
valosekj Aug 13, 2026
350e8a4
Merge branch 'master' into jv/sct_extract_metric_normalize_pam50
valosekj Aug 22, 2026
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
17 changes: 13 additions & 4 deletions spinalcordtoolbox/metrics_to_PAM50.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,19 @@ def interpolate_metrics(metrics, fname_vert_levels_PAM50, fname_vert_levels):
level_slices_PAM50 = [get_slices_from_vertebral_levels(im_seg_labeled_PAM50, level) for level in levels]
level_slices_im = [get_slices_from_vertebral_levels(im_seg_labeled, level) for level in levels]

# Find the mean scaling between the image and PAM50 (excluding first and last levels)
scales = [len(slices_PAM50)/len(slices_im) for slices_PAM50, slices_im
in zip(level_slices_PAM50[1:-1], level_slices_im[1:-1])]
scale_mean = np.mean(scales)
# Find the mean scaling between the image and PAM50 (excluding first and last levels to avoid
# edge effects from potentially incomplete levels)
inner_scales = [len(slices_PAM50)/len(slices_im) for slices_PAM50, slices_im
in zip(level_slices_PAM50[1:-1], level_slices_im[1:-1])
if len(slices_im) > 0]
if inner_scales:
scale_mean = np.mean(inner_scales)
else:
# Fall back to all available levels when fewer than 3 levels are present
# This is done to pass test on mt/mtr.nii.gz and PAM50_levels.nii.gz that only has 2 levels (C4, C5)
all_scales = [len(slices_PAM50)/len(slices_im) for slices_PAM50, slices_im
in zip(level_slices_PAM50, level_slices_im) if len(slices_im) > 0]
scale_mean = np.mean(all_scales) if all_scales else np.nan
Comment thread
joshuacwnewton marked this conversation as resolved.
Outdated

# Initialize a metrics dict filled by NaN with number of rows equal to number of slices in PAM50 template
z = im_seg_labeled_PAM50.dim[2] # z == number of slices
Expand Down
109 changes: 106 additions & 3 deletions spinalcordtoolbox/scripts/sct_extract_metric.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
from spinalcordtoolbox.utils.sys import init_sct, printv, __data_dir__, set_loglevel
from spinalcordtoolbox.utils.fs import check_file_exist, extract_fname, get_absolute_path, TempFolder
from spinalcordtoolbox.scripts import sct_maths
from spinalcordtoolbox.metrics_to_PAM50 import interpolate_metrics
from spinalcordtoolbox.template import get_vertebral_level_from_slice


class Param:
Expand Down Expand Up @@ -206,6 +208,19 @@ def get_parser():
"""),
)

optional.add_argument(
'-normalize-PAM50',
metavar=Metavar.int,
type=int,
choices=[0, 1],
default=0,
help="Set to 1 to interpolate the extracted metric values into PAM50 anatomical "
"dimensions per slice. "
"Requires `-vertfile` and `-perslice 1`. "
"Inspired by: Valošek J, Bédard S et al. Imaging Neuroscience 2024. "
"https://doi.org/10.1162/imag_a_00075"
)
Comment thread
valosekj marked this conversation as resolved.

advanced = parser.add_argument_group("FOR ADVANCED USERS")
advanced.add_argument(
'-param',
Expand Down Expand Up @@ -264,6 +279,70 @@ def get_parser():
return parser


def _build_pam50_agg_metric(agg_metric_native, nz_native, label_name, method,
fname_vert_level, fname_vert_level_PAM50):
Comment thread
valosekj marked this conversation as resolved.
Outdated
"""
Interpolate per-slice native-space metrics into PAM50 anatomical dimensions.
:param agg_metric_native: per-slice native-space metrics (dict output of extract_metric() with perslice=True)
:param nz_native: int: total z-slices in native image
:param label_name: str: atlas label name (for 'Label' CSV column), e.g., 'white matter'
:param method: str: extraction method ('wa', 'ml', 'map', 'bin', 'median', 'max')
:param fname_vert_level: str: native vertebral levels file (centerline-masked)
:param fname_vert_level_PAM50: str: PAM50 template PAM50_levels.nii.gz
:return: dict keyed by (z,) PAM50 slice tuples, suitable for save_as_csv()
"""
method_key_map = {
'wa': 'WA()', 'ml': 'ML()', 'map': 'MAP()',
'bin': 'BIN()', 'median': 'MEDIAN()', 'max': 'MAX()'
}
primary_key = method_key_map[method]

# Build 1D ndarray with per slice values from the agg_metric_native dict
metric_1d = np.full(nz_native, np.nan)
for (z,), entry in agg_metric_native.items():
val = entry.get(primary_key)
if val is not None:
metric_1d[z] = val

# Interpolate to PAM50 space; returns Dict[str, Metric] with 1D data of length z_PAM50
metrics_pam50 = interpolate_metrics(
{primary_key: Metric(data=metric_1d, label=primary_key)},
fname_vert_level_PAM50,
fname_vert_level
)
Comment thread
joshuacwnewton marked this conversation as resolved.
Outdated

# Make the interpolate_metrics output compatible with the expected input of save_as_csv()
# Get 1D ndarray with per-slice metric values in PAM50 space
pam50_values = metrics_pam50[primary_key].data

# Determine which vertebral levels are present in the native data to filter PAM50 output
im_native_levels = Image(fname_vert_level).change_orientation('RPI')
native_levels = set(
int(v) for v in np.unique(im_native_levels.data) if 0 < int(v) < 49
Comment thread
valosekj marked this conversation as resolved.
Outdated
)

# Map each PAM50 z-slice to a vertebral level and build the output agg_metric
im_pam50_levels = Image(fname_vert_level_PAM50).change_orientation('RPI')

agg_metric_pam50 = {}
for z_pam50, val in enumerate(pam50_values):
# nan means that there was no data in the native space for that slice, so we skip it in the PAM50 space as well
if np.isnan(val):
continue
vert_level = get_vertebral_level_from_slice(im_pam50_levels, z_pam50)
if vert_level is None or vert_level not in native_levels:
continue
entry = {
'Label': label_name,
'VertLevel': (vert_level,),
'DistancePMJ': None, # required by save_as_csv() but not relevant for PAM50 space
primary_key: val,
}
agg_metric_pam50[(z_pam50,)] = entry

return agg_metric_pam50


def main(argv: Sequence[str]):
# Ensure that the "-list-labels" argument is always parsed last. That way, if `-f` is passed, then `-list-labels`
# will see the new location and look there. (https://github.com/spinalcordtoolbox/spinalcordtoolbox/issues/3634)
Expand All @@ -289,6 +368,7 @@ def main(argv: Sequence[str]):
fname_vert_level = arguments.vertfile
perslice = arguments.perslice
perlevel = arguments.perlevel
normalize_pam50 = arguments.normalize_PAM50

# check if path_label is a file (e.g., single binary mask) instead of a folder (e.g., SCT atlas structure which
# contains info_label.txt file)
Expand Down Expand Up @@ -384,6 +464,8 @@ def main(argv: Sequence[str]):
f"To use vertebral level information, you may need to run "
f"`sct_warp_template` to generate the appropriate level file in your working directory.", type=message_type)
fname_vert_level = None
if normalize_pam50:
parser.error("Option '-normalize-PAM50' requires a valid '-vertfile'.")
# Get dimensions of data and labels
nx, ny, nz = data.data.shape
nx_atlas, ny_atlas, nz_atlas, nt_atlas = labels.shape
Expand All @@ -409,11 +491,32 @@ def main(argv: Sequence[str]):
map_cluster=None)
labels_id_user = [99]

if normalize_pam50 and not perslice:
parser.error("Option '-normalize-PAM50' requires option '-perslice 1'.")

fname_vert_level_PAM50 = os.path.join(__data_dir__, 'PAM50', 'template', 'PAM50_levels.nii.gz')

for id_label in labels_id_user:
printv('Estimation for label: ' + label_struc[id_label].name, verbose)
agg_metric = extract_metric(data, labels=labels, slices=slices, levels=levels, perslice=perslice,
perlevel=perlevel, fname_vert_level=fname_vert_level, method=method,
label_struc=label_struc, id_label=id_label, indiv_labels_ids=indiv_labels_ids)

if normalize_pam50:
agg_metric_native = extract_metric(
data, labels=labels, slices=slices, levels=levels,
perslice=1, perlevel=0,
fname_vert_level=fname_vert_level, method=method,
label_struc=label_struc, id_label=id_label, indiv_labels_ids=indiv_labels_ids)
agg_metric = _build_pam50_agg_metric(
agg_metric_native=agg_metric_native,
nz_native=nz,
label_name=label_struc[id_label].name,
method=method,
fname_vert_level=fname_vert_level,
fname_vert_level_PAM50=fname_vert_level_PAM50)
else:
agg_metric = extract_metric(
data, labels=labels, slices=slices, levels=levels, perslice=perslice,
perlevel=perlevel, fname_vert_level=fname_vert_level, method=method,
label_struc=label_struc, id_label=id_label, indiv_labels_ids=indiv_labels_ids)
Comment thread
valosekj marked this conversation as resolved.
Outdated

save_as_csv(agg_metric, fname_output, fname_in=fname_data, append=append_csv)
append_csv = True # when looping across labels, need to append results in the same file
Expand Down
48 changes: 48 additions & 0 deletions testing/cli/test_cli_sct_extract_metric.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# pytest unit tests for sct_extract_metric

import csv
import numpy as np

import pytest
Expand Down Expand Up @@ -48,3 +49,50 @@ def test_sct_extract_metric_vertfile_doesnt_exists():
'-vert', '1:12', '-perlevel', '1',
'-method', 'wa', '-l', '51', '-o', fname_out])
assert e.value.code == 2


@pytest.mark.sct_testing
Comment thread
valosekj marked this conversation as resolved.
Outdated
def test_sct_extract_metric_normalize_pam50(tmp_path):
"""Verify that -normalize-PAM50 outputs per-slice metrics in PAM50 space with VertLevel populated."""
fname_out = str(tmp_path / 'quantif_mtr_pam50.csv')
sct_extract_metric.main(argv=[
'-i', sct_test_path('mt', 'mtr.nii.gz'),
'-f', sct_test_path('mt', 'label/atlas'),
'-method', 'wa', '-l', '51',
'-vertfile', sct_test_path('mt', 'label', 'template', 'PAM50_levels.nii.gz'),
'-perslice', '1', '-normalize-PAM50', '1',
'-o', fname_out
])
with open(fname_out, 'r') as f:
rows = list(csv.DictReader(f))
assert len(rows) > 0
assert all(r['VertLevel'] != '' for r in rows)
assert all(r['WA()'] not in ('', 'nan') for r in rows)
Comment thread
valosekj marked this conversation as resolved.
Outdated


def test_sct_extract_metric_normalize_pam50_missing_perslice(tmp_path):
"""Verify that -normalize-PAM50 1 without -perslice 1 raises an error."""
with pytest.raises(SystemExit) as e:
sct_extract_metric.main(argv=[
'-i', sct_test_path('mt', 'mtr.nii.gz'),
'-f', sct_test_path('mt', 'label/atlas'),
'-method', 'wa', '-l', '51',
'-vertfile', sct_test_path('mt', 'label', 'template', 'PAM50_levels.nii.gz'),
'-normalize-PAM50', '1',
'-o', str(tmp_path / 'out.csv')
])
assert e.value.code == 2


def test_sct_extract_metric_normalize_pam50_missing_vertfile(tmp_path):
"""Verify that -normalize-PAM50 1 with a missing vertfile raises an error."""
with pytest.raises(SystemExit) as e:
sct_extract_metric.main(argv=[
'-i', sct_test_path('mt', 'mtr.nii.gz'),
'-f', sct_test_path('mt', 'label/atlas'),
'-method', 'wa', '-l', '51',
'-vertfile', sct_test_path('mt', 'label', 'template', 'does_not_exist.nii.gz'),
'-perslice', '1', '-normalize-PAM50', '1',
'-o', str(tmp_path / 'out.csv')
])
assert e.value.code == 2
Loading