From 5b8c1a1ae3f9d15eafe743de23f7a29345f658ac Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Wed, 24 Sep 2025 14:54:48 +0200 Subject: [PATCH 1/9] update: change logic to increment ROI values in merge_parcellations --- junifer/data/parcellations/_parcellations.py | 40 ++++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/junifer/data/parcellations/_parcellations.py b/junifer/data/parcellations/_parcellations.py index b6fb09b7d..f63f9ddcf 100644 --- a/junifer/data/parcellations/_parcellations.py +++ b/junifer/data/parcellations/_parcellations.py @@ -1346,34 +1346,47 @@ def merge_parcellations( ] overlapping_voxels = False ref_parc = parcellations_list[0] - parc_data = ref_parc.get_fdata() + ref_parc_data = ref_parc.get_fdata() labels = labels_lists[0] - for t_parc, t_labels in zip(parcellations_list[1:], labels_lists[1:]): - if t_parc.shape != ref_parc.shape: + # Get max value for the parcellation ROIs to get a reference for ROI value + # increment + max_val = np.max( + np.concatenate( + [np.unique(p.get_fdata().astype(int)) for p in parcellations_list] + ) + ) + + for idx, (parc, labs) in enumerate( + zip(parcellations_list[1:], labels_lists[1:]) + ): + # Resample to reference (1st in the list) parcellation + if parc.shape != ref_parc.shape: warn_with_log( "The parcellations have different resolutions!" "Resampling all parcellations to the first one in the list." ) - t_parc = nimg.resample_to_img( - t_parc, ref_parc, interpolation="nearest", copy=True + parc = nimg.resample_to_img( + source_img=parc, + target_img=ref_parc, + interpolation="nearest", + copy=True, ) - # Get the data from this parcellation - t_parc_data = t_parc.get_fdata().copy() # must be copied + parc_data = parc.get_fdata().copy() # must be copied # Increase the values of each ROI to match the labels - t_parc_data[t_parc_data != 0] += len(labels) + parc_data[parc_data != 0] += (idx + 1) * max_val # Only set new values for the voxels that are 0 # This makes sure that the voxels that are in multiple # parcellations are assigned to the parcellation that was # first in the list. - if np.any(parc_data[t_parc_data != 0] != 0): + if np.any(ref_parc_data[parc_data != 0] != 0): overlapping_voxels = True - parc_data[parc_data == 0] += t_parc_data[parc_data == 0] - labels.extend(t_labels) + ref_parc_data[ref_parc_data == 0] += parc_data[ref_parc_data == 0] + labels.extend(labs) if overlapping_voxels: warn_with_log( @@ -1382,6 +1395,9 @@ def merge_parcellations( "parcellation that was first in the list." ) - parcellation_img_res = nimg.new_img_like(parcellations_list[0], parc_data) + parcellation_img_res = nimg.new_img_like( + ref_niimg=parcellations_list[0], + data=ref_parc_data, + ) return parcellation_img_res, labels -- 2.52.0 From 03845fffb7478ff2faf12e6f620fe8e4d94f59ae Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Wed, 24 Sep 2025 14:56:13 +0200 Subject: [PATCH 2/9] chore: improve log message in merge_parcellations --- junifer/data/parcellations/_parcellations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/junifer/data/parcellations/_parcellations.py b/junifer/data/parcellations/_parcellations.py index f63f9ddcf..83d7cba7c 100644 --- a/junifer/data/parcellations/_parcellations.py +++ b/junifer/data/parcellations/_parcellations.py @@ -1364,7 +1364,7 @@ def merge_parcellations( # Resample to reference (1st in the list) parcellation if parc.shape != ref_parc.shape: warn_with_log( - "The parcellations have different resolutions!" + "The parcellations have different resolutions. " "Resampling all parcellations to the first one in the list." ) parc = nimg.resample_to_img( -- 2.52.0 From fec63786962a28f15b21abb5fb7d7c6668355182 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Wed, 24 Sep 2025 15:17:03 +0200 Subject: [PATCH 3/9] update: remove constraint for non-continuous parcellations --- junifer/data/parcellations/_parcellations.py | 6 --- .../parcellations/tests/test_parcellations.py | 41 ------------------- 2 files changed, 47 deletions(-) diff --git a/junifer/data/parcellations/_parcellations.py b/junifer/data/parcellations/_parcellations.py index 83d7cba7c..7365ba44e 100644 --- a/junifer/data/parcellations/_parcellations.py +++ b/junifer/data/parcellations/_parcellations.py @@ -413,12 +413,6 @@ class ParcellationRegistry(BasePipelineDataRegistry): ) # Sort values parcel_values.sort() - # Check if value range is invalid - if np.any(np.diff(parcel_values) != 1): - raise_error( - f"Parcellation {name} must have all the values in the " - f"range [0, {len(parcel_values)}]" - ) return parcellation_img, parcellation_labels, parcellation_fname, space diff --git a/junifer/data/parcellations/tests/test_parcellations.py b/junifer/data/parcellations/tests/test_parcellations.py index 906bc48c6..a4e4a5a87 100644 --- a/junifer/data/parcellations/tests/test_parcellations.py +++ b/junifer/data/parcellations/tests/test_parcellations.py @@ -7,7 +7,6 @@ from pathlib import Path -import nibabel as nib import numpy as np import pytest from nilearn.image import new_img_like, resample_to_img @@ -153,46 +152,6 @@ def test_parcellation_wrong_labels_values(tmp_path: Path) -> None: target_space="MNI152NLin6Asym", ) - schaefer_data = schaefer.get_fdata().copy() - schaefer_data[schaefer_data == 50] = 0 - new_schaefer_path = tmp_path / "new_schaefer.nii.gz" - new_schaefer_img = new_img_like(schaefer, schaefer_data) - nib.save(new_schaefer_img, new_schaefer_path) - - register_data( - kind="parcellation", - name="WrongValues", - parcellation_path=new_schaefer_path, - parcels_labels=labels[:-1], - space="MNI152Lin", - ) - with pytest.raises(ValueError, match=r"must have all the values in the"): - load_data( - kind="parcellation", - name="WrongValues", - target_space="MNI152NLin6Asym", - ) - - schaefer_data = schaefer.get_fdata().copy() - schaefer_data[schaefer_data == 50] = 200 - new_schaefer_path = tmp_path / "new_schaefer2.nii.gz" - new_schaefer_img = new_img_like(schaefer, schaefer_data) - nib.save(new_schaefer_img, new_schaefer_path) - - register_data( - kind="parcellation", - name="WrongValues2", - parcellation_path=new_schaefer_path, - parcels_labels=labels, - space="MNI152Lin", - ) - with pytest.raises(ValueError, match=r"must have all the values in the"): - load_data( - kind="parcellation", - name="WrongValues2", - target_space="MNI152NLin6Asym", - ) - @pytest.mark.parametrize( "name, parcellation_path, parcels_labels, space, overwrite", -- 2.52.0 From 3e0114e62ad31dffaa0a10d9caf2581f3fbca510 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Wed, 24 Sep 2025 15:18:00 +0200 Subject: [PATCH 4/9] chore: remove extra sort in ParcellationRegistry.load --- junifer/data/parcellations/_parcellations.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/junifer/data/parcellations/_parcellations.py b/junifer/data/parcellations/_parcellations.py index 7365ba44e..4e917f7aa 100644 --- a/junifer/data/parcellations/_parcellations.py +++ b/junifer/data/parcellations/_parcellations.py @@ -403,7 +403,7 @@ class ParcellationRegistry(BasePipelineDataRegistry): if not path_only: # Load image via nibabel parcellation_img = nib.load(parcellation_fname) - # Get unique values + # Get unique values (returns sorted result) parcel_values = np.unique(parcellation_img.get_fdata()) # Check for dimension if len(parcel_values) - 1 != len(parcellation_labels): @@ -411,8 +411,6 @@ class ParcellationRegistry(BasePipelineDataRegistry): f"Parcellation {name} has {len(parcel_values) - 1} " f"parcels but {len(parcellation_labels)} labels." ) - # Sort values - parcel_values.sort() return parcellation_img, parcellation_labels, parcellation_fname, space -- 2.52.0 From 86a15bb23f5a14863a8977100f6e9f65de4905c9 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 6 Oct 2025 16:08:38 +0200 Subject: [PATCH 5/9] update: make ParcellationRegistry.get return labels as dict of int to str --- junifer/data/_dispatch.py | 6 ++- junifer/data/parcellations/_parcellations.py | 40 ++++++++++++++------ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/junifer/data/_dispatch.py b/junifer/data/_dispatch.py index dbf75de7c..bf031a0c8 100644 --- a/junifer/data/_dispatch.py +++ b/junifer/data/_dispatch.py @@ -120,7 +120,8 @@ def get_data( extra_input: Optional[dict[str, Any]] = None, ) -> Union[ tuple[ArrayLike, list[str]], # coordinates - tuple["Nifti1Image", list[str]], # parcellation / maps + tuple["Nifti1Image", dict[int, str]], # parcellation + tuple["Nifti1Image", list[str]], # maps "Nifti1Image", # mask ]: """Get tailored ``kind`` for ``target_data``. @@ -142,7 +143,8 @@ def get_data( Returns ------- tuple of numpy.ndarray, list of str; \ - tuple of nibabel.nifti1.Nifti1Image, list of str; \ + tuple of nibabel.nifti1.Nifti1Image, dict of int and str or, + list of str; \ nibabel.nifti1.Nifti1Image Raises diff --git a/junifer/data/parcellations/_parcellations.py b/junifer/data/parcellations/_parcellations.py index 4e917f7aa..8d51b0be7 100644 --- a/junifer/data/parcellations/_parcellations.py +++ b/junifer/data/parcellations/_parcellations.py @@ -419,7 +419,7 @@ class ParcellationRegistry(BasePipelineDataRegistry): parcellations: Union[str, list[str]], target_data: dict[str, Any], extra_input: Optional[dict[str, Any]] = None, - ) -> tuple["Nifti1Image", list[str]]: + ) -> tuple["Nifti1Image", dict[int, str]]: """Get parcellation, tailored for the target image. Parameters @@ -438,8 +438,8 @@ class ParcellationRegistry(BasePipelineDataRegistry): ------- Nifti1Image The parcellation image. - list of str - Parcellation labels. + dict of int and str + Parcellation value to label mappings. Raises ------ @@ -553,7 +553,16 @@ class ParcellationRegistry(BasePipelineDataRegistry): # Avoid merging if there is only one parcellation if len(all_parcellations) == 1: resampled_parcellation_img = all_parcellations[0] - labels = all_labels[0] + labels = dict( + zip( + np.trim_zeros( + np.unique( + resampled_parcellation_img.get_fdata().astype(int) + ) + ), + all_labels[0], + ) + ) # Parcellations are already transformed to target standard space else: logger.debug("Merging parcellations.") @@ -1302,7 +1311,7 @@ def merge_parcellations( parcellations_list: list["Nifti1Image"], parcellations_names: list[str], labels_lists: list[list[str]], -) -> tuple["Nifti1Image", list[str]]: +) -> tuple["Nifti1Image", dict[int, str]]: """Merge multiple parcellations. Parameters @@ -1320,8 +1329,8 @@ def merge_parcellations( Niimg-like object The parcellation that results from merging the list of input parcellations. - list of str - List of labels for the resultant parcellation. + dict of int and str + Merged parcellation value to label mappings. """ # Check for duplicated labels @@ -1340,8 +1349,6 @@ def merge_parcellations( ref_parc = parcellations_list[0] ref_parc_data = ref_parc.get_fdata() - labels = labels_lists[0] - # Get max value for the parcellation ROIs to get a reference for ROI value # increment max_val = np.max( @@ -1349,6 +1356,13 @@ def merge_parcellations( [np.unique(p.get_fdata().astype(int)) for p in parcellations_list] ) ) + # Setup parcellation value to label mapping + val_label_map = dict( + zip( + np.trim_zeros(np.unique(ref_parc_data.astype(int))), + labels_lists[0], + ), + ) for idx, (parc, labs) in enumerate( zip(parcellations_list[1:], labels_lists[1:]) @@ -1368,8 +1382,11 @@ def merge_parcellations( # Get the data from this parcellation parc_data = parc.get_fdata().copy() # must be copied # Increase the values of each ROI to match the labels + # and update label mapping parc_data[parc_data != 0] += (idx + 1) * max_val - + val_label_map.update( + dict(zip(np.trim_zeros(np.unique(parc_data.astype(int))), labs)) + ) # Only set new values for the voxels that are 0 # This makes sure that the voxels that are in multiple # parcellations are assigned to the parcellation that was @@ -1378,7 +1395,6 @@ def merge_parcellations( overlapping_voxels = True ref_parc_data[ref_parc_data == 0] += parc_data[ref_parc_data == 0] - labels.extend(labs) if overlapping_voxels: warn_with_log( @@ -1392,4 +1408,4 @@ def merge_parcellations( data=ref_parc_data, ) - return parcellation_img_res, labels + return parcellation_img_res, val_label_map -- 2.52.0 From 90c0dfb82a38bc65a1ca6f1c353d2a48b2d59294 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 6 Oct 2025 16:09:40 +0200 Subject: [PATCH 6/9] update: adapt ParcelAggregation impl and test to updated ParcellationRegistry.get --- junifer/markers/parcel_aggregation.py | 4 ++-- junifer/markers/tests/test_parcel_aggregation.py | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/junifer/markers/parcel_aggregation.py b/junifer/markers/parcel_aggregation.py index 81ad4ca32..ca9cd3911 100644 --- a/junifer/markers/parcel_aggregation.py +++ b/junifer/markers/parcel_aggregation.py @@ -213,7 +213,7 @@ class ParcelAggregation(BaseMarker): logger.debug("Computing ROI means") out_values = [] # Iterate over the parcels (existing) - for t_v in range(1, len(labels) + 1): + for t_v in labels.keys(): t_values = agg_func(data[:, parcellation_values == t_v], axis=-1) out_values.append(t_values) # Update the labels just in case a parcel has no voxels @@ -238,6 +238,6 @@ class ParcelAggregation(BaseMarker): return { "aggregation": { "data": out_values, - "col_names": labels, + "col_names": list(labels.values()), }, } diff --git a/junifer/markers/tests/test_parcel_aggregation.py b/junifer/markers/tests/test_parcel_aggregation.py index 72e1f03eb..6e7098073 100644 --- a/junifer/markers/tests/test_parcel_aggregation.py +++ b/junifer/markers/tests/test_parcel_aggregation.py @@ -408,8 +408,8 @@ def test_ParcelAggregation_3D_multiple_non_overlapping(tmp_path: Path) -> None: parcellation2_data = parcellation_data.copy() parcellation2_data[parcellation2_data <= 8] = 0 parcellation2_data[parcellation2_data > 0] -= 8 - labels1 = labels[:8] - labels2 = labels[8:] + labels1 = list(labels.values())[:8] + labels2 = list(labels.values())[8:] parcellation1_img = new_img_like( testing_parcellation, parcellation1_data @@ -512,8 +512,9 @@ def test_ParcelAggregation_3D_multiple_overlapping(tmp_path: Path) -> None: # Make the second parcellation overlap with the first parcellation2_data[parcellation2_data <= 6] = 0 parcellation2_data[parcellation2_data > 0] -= 6 - labels1 = [f"low_{x}" for x in labels[:8]] # Change the labels - labels2 = [f"high_{x}" for x in labels[6:]] # Change the labels + # Change the labels + labels1 = [f"low_{x}" for x in list(labels.values())[:8]] + labels2 = [f"high_{x}" for x in list(labels.values())[6:]] parcellation1_img = new_img_like( testing_parcellation, parcellation1_data @@ -621,8 +622,8 @@ def test_ParcelAggregation_3D_multiple_duplicated_labels( parcellation2_data = parcellation_data.copy() parcellation2_data[parcellation2_data <= 8] = 0 parcellation2_data[parcellation2_data > 0] -= 8 - labels1 = labels[:8] - labels2 = labels[7:-1] # One label is duplicated + labels1 = list(labels.values())[:8] + labels2 = list(labels.values())[7:-1] # One label is duplicated parcellation1_img = new_img_like( testing_parcellation, parcellation1_data -- 2.52.0 From 33d72c7bee2f9eeb4ee811d41679cc3233a06039 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 6 Oct 2025 16:18:18 +0200 Subject: [PATCH 7/9] chore: fix test for ParcellationRegistry --- junifer/data/parcellations/tests/test_parcellations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/junifer/data/parcellations/tests/test_parcellations.py b/junifer/data/parcellations/tests/test_parcellations.py index a4e4a5a87..8832106c0 100644 --- a/junifer/data/parcellations/tests/test_parcellations.py +++ b/junifer/data/parcellations/tests/test_parcellations.py @@ -1131,7 +1131,7 @@ def test_get_single() -> None: tailored_parcellation.get_fdata(), resampled_raw_parcellation.get_fdata(), ) - assert tailored_labels == raw_labels + assert list(tailored_labels.values()) == raw_labels def test_get_multi_same_space() -> None: -- 2.52.0 From 1150a1e83558a600a08fa61b4fcfee13c82d1bd1 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 6 Oct 2025 17:16:59 +0200 Subject: [PATCH 8/9] chore: update commentary --- junifer/data/_dispatch.py | 3 +-- junifer/data/parcellations/_parcellations.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/junifer/data/_dispatch.py b/junifer/data/_dispatch.py index bf031a0c8..99305dfff 100644 --- a/junifer/data/_dispatch.py +++ b/junifer/data/_dispatch.py @@ -143,8 +143,7 @@ def get_data( Returns ------- tuple of numpy.ndarray, list of str; \ - tuple of nibabel.nifti1.Nifti1Image, dict of int and str or, - list of str; \ + tuple of nibabel.nifti1.Nifti1Image, dict or list of str; \ nibabel.nifti1.Nifti1Image Raises diff --git a/junifer/data/parcellations/_parcellations.py b/junifer/data/parcellations/_parcellations.py index 8d51b0be7..0f3efafb3 100644 --- a/junifer/data/parcellations/_parcellations.py +++ b/junifer/data/parcellations/_parcellations.py @@ -438,7 +438,7 @@ class ParcellationRegistry(BasePipelineDataRegistry): ------- Nifti1Image The parcellation image. - dict of int and str + dict Parcellation value to label mappings. Raises -- 2.52.0 From f2b88528f7fd763a165da92b4c5f3f08c8550ceb Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 6 Oct 2025 17:17:17 +0200 Subject: [PATCH 9/9] chore: add changelogs 257.{change,enh} --- docs/changes/newsfragments/257.change | 1 + docs/changes/newsfragments/257.enh | 1 + 2 files changed, 2 insertions(+) create mode 100644 docs/changes/newsfragments/257.change create mode 100644 docs/changes/newsfragments/257.enh diff --git a/docs/changes/newsfragments/257.change b/docs/changes/newsfragments/257.change new file mode 100644 index 000000000..4171dc585 --- /dev/null +++ b/docs/changes/newsfragments/257.change @@ -0,0 +1 @@ +``ParcellationRegistry.get`` now returns labels as dictionary mapping from values to labels instead of a list of labels by `Synchon Mandal`_ diff --git a/docs/changes/newsfragments/257.enh b/docs/changes/newsfragments/257.enh new file mode 100644 index 000000000..d5016a643 --- /dev/null +++ b/docs/changes/newsfragments/257.enh @@ -0,0 +1 @@ +Allow parcellation registration with non-continuous ranges as values by `Synchon Mandal`_ -- 2.52.0