Skip to content

Commit 2a73103

Browse files
committed
WIP: map idps to figure
1 parent 3407673 commit 2a73103

File tree

2 files changed

+198
-1
lines changed

2 files changed

+198
-1
lines changed

antspymm/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,4 @@
143143
from .mm import distortion_correct_bvecs
144144
from .mm import apply_transforms_mixed_interpolation
145145
from .mm import t1w_super_resolution_with_hemispheres
146+
from .mm import map_idps_to_rois

antspymm/mm.py

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13145,4 +13145,200 @@ def t1w_super_resolution_with_hemispheres(
1314513145

1314613146
if verbose:
1314713147
print("Done super-resolution.")
13148-
return sr_image
13148+
return sr_image
13149+
13150+
13151+
def map_idps_to_rois(
13152+
idp_data_frame: pd.DataFrame,
13153+
roi_image: ants.ANTsImage,
13154+
idp_column: str,
13155+
map_type: str = 'average'
13156+
) -> ants.ANTsImage:
13157+
"""
13158+
Produces a new ANTsImage where each ROI is assigned a value based on IDP data
13159+
from a DataFrame. ROIs are identified by integer labels in `roi_image`
13160+
and values are linked via `idp_data_frame`.
13161+
13162+
Assumes `idp_data_frame` contains both 'Label' (integer ROI ID) and
13163+
'Description' (string description for the ROI, e.g., 'left caudal anterior cingulate')
13164+
columns, in addition to the specified `idp_column`.
13165+
13166+
Parameters:
13167+
- idp_data_frame (pd.DataFrame): DataFrame containing IDP measurements.
13168+
Must have 'Label', 'Description' (for hemisphere parsing), and `idp_column`.
13169+
- roi_image (ants.ANTsImage): An ANTsImage where each voxel contains an integer
13170+
label identifying an ROI.
13171+
- idp_column (str): The name of the column in `idp_data_frame` whose values
13172+
are to be mapped to the ROIs (e.g., 'VolumeInMillimeters').
13173+
- map_type (str): Type of mapping to perform.
13174+
- 'average': For identified paired left/right ROIs, their `idp_column` values are
13175+
averaged and this average is assigned to both the left and right
13176+
hemisphere ROIs in the output image. If only one side of a pair
13177+
is found, its raw value is used.
13178+
- 'asymmetry': For identified paired left/right ROIs, the (Left - Right)
13179+
difference for `idp_column` is calculated and assigned only
13180+
to the left hemisphere ROI. Right hemisphere ROIs that are
13181+
part of a pair, and any unpaired ROIs, will be set to 0 in
13182+
the output image.
13183+
- 'raw': Each ROI's original value from `idp_column` is mapped directly to
13184+
its corresponding ROI in the output image.
13185+
Default is 'average'.
13186+
13187+
Returns:
13188+
- ants.ANTsImage: A new ANTsImage with the same header (origin, spacing,
13189+
direction, etc.) as `roi_image`, where ROI voxels are filled
13190+
with the mapped IDP values. Voxels not part of any described
13191+
ROI, or unmatched based on `map_type`, will be 0.
13192+
13193+
Raises:
13194+
- ValueError: If required columns (`Label`, `Description`, `idp_column`) are missing
13195+
from `idp_data_frame`, `roi_image` is not an ANTsImage,
13196+
or `map_type` is invalid.
13197+
"""
13198+
logging.info(f"Starting map_idps_to_rois (map_type='{map_type}', IDP column='{idp_column}')")
13199+
13200+
# --- 1. Input Validation ---
13201+
required_idp_cols = ['Label', 'Description', idp_column]
13202+
if not all(col in idp_data_frame.columns for col in required_idp_cols):
13203+
raise ValueError(f"idp_data_frame must contain columns: {', '.join(required_idp_cols)}")
13204+
13205+
if not isinstance(roi_image, ants.ANTsImage):
13206+
raise ValueError("roi_image must be an ants.ANTsImage object.")
13207+
13208+
valid_map_types = ['average', 'asymmetry', 'raw']
13209+
if map_type not in valid_map_types:
13210+
raise ValueError(f"Invalid map_type: '{map_type}'. Must be one of {valid_map_types}.")
13211+
13212+
# --- 2. Prepare Data (use idp_data_frame directly) ---
13213+
# Select only the necessary columns from the input idp_data_frame
13214+
processed_idp_df = idp_data_frame[['Label', 'Description', idp_column]].copy()
13215+
processed_idp_df.rename(columns={idp_column: 'Value'}, inplace=True)
13216+
13217+
# Ensure 'Label' column is numeric and drop rows where conversion fails
13218+
processed_idp_df['Label'] = pd.to_numeric(processed_idp_df['Label'], errors='coerce')
13219+
processed_idp_df = processed_idp_df.dropna(subset=['Label']) # Drop rows where Label is NaN after coercion
13220+
processed_idp_df['Label'] = processed_idp_df['Label'].astype(int) # Convert to integer labels
13221+
13222+
logging.info(f"Processed IDP data contains {len(processed_idp_df)} entries. "
13223+
f"{processed_idp_df['Value'].isnull().sum()} entries have no valid IDP value (NaN).")
13224+
13225+
# --- 3. Identify Hemispheres and Base Regions ---
13226+
# This helper function parses the ROI description to determine its hemisphere
13227+
# and a common base name for pairing (e.g., 'caudal anterior cingulate').
13228+
def get_hemisphere_and_base(description):
13229+
desc = str(description).strip()
13230+
13231+
# Pattern 1: FreeSurfer-like (e.g., "left caudal anterior cingulate")
13232+
match_fs = re.match(r"^(left|right)\s(.+)$", desc, re.IGNORECASE)
13233+
if match_fs:
13234+
return match_fs.group(1).capitalize(), match_fs.group(2).strip()
13235+
13236+
# Pattern 2: BN_STR-like (e.g., "BN_STR_Pu_Left")
13237+
match_bn = re.match(r"(.+)_(Left|Right)$", desc, re.IGNORECASE)
13238+
if match_bn:
13239+
return match_bn.group(2).capitalize(), match_bn.group(1).strip()
13240+
13241+
# No clear hemisphere identified (e.g., 'corpus callosum')
13242+
return 'Unknown', desc
13243+
13244+
processed_idp_df[['Hemisphere', 'BaseRegion']] = processed_idp_df['Description'].apply(
13245+
lambda x: pd.Series(get_hemisphere_and_base(x))
13246+
)
13247+
13248+
# Dictionary to store the final computed value for each ROI Label
13249+
label_value_map = {}
13250+
13251+
# --- 4. Process Values based on map_type ---
13252+
if map_type == 'raw':
13253+
logging.info("Mapping raw IDP values to ROIs.")
13254+
# Directly map values where available
13255+
for _, row in processed_idp_df.dropna(subset=['Value']).iterrows():
13256+
label_value_map[row['Label']] = row['Value']
13257+
13258+
else: # 'average' or 'asymmetry' types which require pairing logic
13259+
# Group by the 'BaseRegion' to find potential left/right pairs
13260+
grouped = processed_idp_df.groupby('BaseRegion')
13261+
13262+
for base_region, group_df in grouped:
13263+
left_roi_data = group_df[group_df['Hemisphere'] == 'Left'].dropna(subset=['Value'])
13264+
right_roi_data = group_df[group_df['Hemisphere'] == 'Right'].dropna(subset=['Value'])
13265+
13266+
# Handle ROIs that are not clearly left/right (e.g., 'Bilateral' or 'Unknown')
13267+
# For these, we include their raw value regardless of map_type.
13268+
other_rois = group_df[ (group_df['Hemisphere'] != 'Left') & (group_df['Hemisphere'] != 'Right') ].dropna(subset=['Value'])
13269+
for _, row in other_rois.iterrows():
13270+
label_value_map[row['Label']] = row['Value']
13271+
logging.debug(f"ROI: '{base_region}' (Label: {row['Label']}) - Not a pair, mapped raw value: {row['Value']:.2f}")
13272+
13273+
# Process paired regions if both left and right data are available
13274+
if not left_roi_data.empty and not right_roi_data.empty:
13275+
l_label = left_roi_data['Label'].iloc[0]
13276+
l_value = left_roi_data['Value'].iloc[0]
13277+
r_label = right_roi_data['Label'].iloc[0]
13278+
r_value = right_roi_data['Value'].iloc[0]
13279+
13280+
if map_type == 'average':
13281+
avg_val = (l_value + r_value) / 2
13282+
label_value_map[l_label] = avg_val
13283+
label_value_map[r_label] = avg_val
13284+
logging.debug(f"ROI: '{base_region}' - Paired AVG: {avg_val:.2f} (L:{l_value:.2f}, R:{r_value:.2f})")
13285+
elif map_type == 'asymmetry':
13286+
asym_val = l_value - r_value
13287+
label_value_map[l_label] = asym_val
13288+
# Right ROI is not assigned a value based on asymmetry, so it retains 0
13289+
logging.debug(f"ROI: '{base_region}' - Paired ASYM (L-R): {asym_val:.2f} (L:{l_value:.2f}, R:{r_value:.2f})")
13290+
else:
13291+
# If only one side of a pair (or neither) is found with a valid value
13292+
if map_type == 'average':
13293+
if not left_roi_data.empty:
13294+
label_value_map[left_roi_data['Label'].iloc[0]] = left_roi_data['Value'].iloc[0]
13295+
logging.debug(f"ROI: '{base_region}' - L-only AVG (raw): {left_roi_data['Value'].iloc[0]:.2f}")
13296+
if not right_roi_data.empty:
13297+
label_value_map[right_roi_data['Label'].iloc[0]] = right_roi_data['Value'].iloc[0]
13298+
logging.debug(f"ROI: '{base_region}' - R-only AVG (raw): {right_roi_data['Value'].iloc[0]:.2f}")
13299+
elif map_type == 'asymmetry':
13300+
# If only one hemisphere's data is available, asymmetry cannot be computed.
13301+
# For asymmetry map_type, any unpaired ROI (including left) gets 0.
13302+
if not left_roi_data.empty:
13303+
label_value_map[left_roi_data['Label'].iloc[0]] = 0.0
13304+
logging.debug(f"ROI: '{base_region}' - L-only ASYM: Set to 0.0 (no pair for computation).")
13305+
if not right_roi_data.empty:
13306+
logging.debug(f"ROI: '{base_region}' - R-only ASYM: Not assigned (relevant for left hemisphere only).")
13307+
13308+
# --- 5. Populate Output Image Array ---
13309+
# Initialize the output NumPy array with zeros, using the robust float32 type
13310+
output_numpy = np.zeros(roi_image.shape, dtype=np.float32)
13311+
# Get the input ROI image's data as a NumPy array for fast lookups
13312+
roi_image_numpy = roi_image.numpy()
13313+
13314+
total_voxels_mapped = 0
13315+
unique_labels_mapped_in_image = set() # Track unique labels actually processed in the image
13316+
13317+
# Iterate through the `label_value_map` to assign values to the output image
13318+
for label_id, value in label_value_map.items():
13319+
# Only map if the value is not NaN (means it had data from IDP, valid conversion, etc.)
13320+
if not np.isnan(value):
13321+
# Find all voxels in `roi_image_numpy` that match the current `label_id`
13322+
matching_indices = np.where(roi_image_numpy == int(label_id))
13323+
13324+
if matching_indices[0].size > 0: # Check if this label actually exists in roi_image
13325+
output_numpy[matching_indices] = value
13326+
total_voxels_mapped += matching_indices[0].size
13327+
unique_labels_mapped_in_image.add(label_id)
13328+
13329+
logging.info(f"Mapped values for {len(unique_labels_mapped_in_image)} unique ROI labels found in `roi_image`, affecting {total_voxels_mapped} voxels.")
13330+
logging.info("Unmapped ROIs in `roi_image` (not present in `idp_data_frame` or outside processing scope) retain value of 0.")
13331+
13332+
# --- 6. Create ANTsImage Output ---
13333+
# Construct the final ANTsImage from the populated NumPy array,
13334+
# preserving the spatial header information from the original `roi_image`.
13335+
output_image = ants.from_numpy(
13336+
output_numpy,
13337+
origin=roi_image.origin,
13338+
spacing=roi_image.spacing,
13339+
direction=roi_image.direction
13340+
)
13341+
13342+
logging.info("map_idps_to_rois completed successfully.")
13343+
return output_image
13344+

0 commit comments

Comments
 (0)