@@ -13145,4 +13145,200 @@ def t1w_super_resolution_with_hemispheres(
13145
13145
13146
13146
if verbose:
13147
13147
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