@@ -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