"""ROI border overlays for Huth story-listening flatmaps.""" from __future__ import annotations from dataclasses import dataclass from pathlib import Path import h5py import numpy as np from PIL import Image, ImageDraw from scipy import sparse from scipy.ndimage import binary_closing, binary_dilation, binary_erosion from flatmap_plotting import text_size @dataclass(frozen=True) class ROIGroup: label: str description: str color: tuple[int, int, int] rois: tuple[str, ...] ROI_GROUPS = ( ROIGroup("AC", "auditory cortex", (0, 220, 255), ("AC",)), ROIGroup("Lang-frontal", "Broca", (255, 180, 0), ("Broca",)), ROIGroup("Lang-temporal", "STG/STS/LTC/TC", (255, 80, 0), ("STG", "STG_old", "STS", "LTC", "TC")), ROIGroup("Lang-parietal", "LPC/cIPL", (255, 225, 0), ("LPC", "cIPL")), ROIGroup( "High-level visual", "PPA/RSC/OPA/FFA/FBA/OFA/EBA/LO/VTC/hMT", (255, 35, 210), ("PPA", "RSC", "OPA", "FFA", "FBA", "OFA", "EBA", "LO", "VTC", "hMT"), ), ROIGroup("Dorsal attention", "IPS/FEF/SEF", (170, 105, 255), ("IPS", "FEF", "SEF")), ROIGroup("Frontal control", "FC/FO/MPC", (65, 95, 255), ("FC", "FO", "MPC")), ) def load_roi_group_masks(data_root: Path, subject: str, threshold: float = 0.25) -> list[dict[str, object]]: mapper_path = data_root / "mappers" / f"{subject}_mappers.hdf" groups = [] with h5py.File(mapper_path, "r") as h5: shape = tuple(int(value) for value in h5["voxel_to_flatmap_shape"][:]) mapper = sparse.csr_matrix( ( h5["voxel_to_flatmap_data"][:], h5["voxel_to_flatmap_indices"][:], h5["voxel_to_flatmap_indptr"][:], ), shape=shape, ) flatmap_mask = h5["flatmap_mask"][:].astype(bool) denominator = np.asarray(mapper @ np.ones(shape[1], dtype=np.float32), dtype=np.float32) active = denominator > 1e-6 for group in ROI_GROUPS: available = [name for name in group.rois if f"roi_mask_{name}" in h5] if not available: continue voxel_mask = np.zeros(shape[1], dtype=np.float32) for name in available: voxel_mask = np.maximum(voxel_mask, (np.asarray(h5[f"roi_mask_{name}"][:]) > 0.5).astype(np.float32)) weighted = np.asarray(mapper @ voxel_mask, dtype=np.float32) coverage = np.zeros_like(weighted) coverage[active] = weighted[active] / denominator[active] flatmap_roi = np.zeros(flatmap_mask.shape, dtype=bool) flatmap_roi[flatmap_mask] = coverage > threshold if np.any(flatmap_roi): groups.append( { "label": group.label, "description": group.description, "color": group.color, "rois": available, "mask": flatmap_roi, } ) return groups def border_from_mask(mask: np.ndarray, panel_mask: np.ndarray, width: int, smooth_iterations: int) -> np.ndarray: if not np.any(mask): return np.zeros_like(mask, dtype=bool) structure = np.ones((3, 3), dtype=bool) smooth_mask = mask if smooth_iterations > 0: smooth_mask = binary_closing(mask, structure=structure, iterations=smooth_iterations) border = binary_dilation(smooth_mask, structure=structure, iterations=width) & ~binary_erosion( smooth_mask, structure=structure, iterations=width, border_value=0, ) return border & panel_mask def overlay_roi_borders( image: Image.Image, roi_groups: list[dict[str, object]], crop: tuple[slice, slice], panel_mask: np.ndarray, width: int = 1, smooth_iterations: int = 1, dot_step: int = 7, ) -> Image.Image: if not roi_groups: return image rgba = np.asarray(image.convert("RGBA")).copy() for group in roi_groups: roi_mask = np.asarray(group["mask"], dtype=bool)[crop] & panel_mask border = border_from_mask(roi_mask, panel_mask, width, smooth_iterations) if not np.any(border): continue halo = binary_dilation(border, structure=np.ones((3, 3), dtype=bool), iterations=1) & panel_mask rgba[halo, :3] = (20, 20, 20) rgba[halo, 3] = 255 if dot_step > 0: rows, cols = np.indices(roi_mask.shape) dots = roi_mask & panel_mask & ~border & (((rows * 3 + cols * 5) % dot_step) == 0) rgba[dots, :3] = group["color"] rgba[dots, 3] = 255 rgba[border, :3] = group["color"] rgba[border, 3] = 255 return Image.fromarray(rgba, mode="RGBA") def roi_group_metadata(roi_groups: list[dict[str, object]]) -> list[dict[str, object]]: return [ { "label": str(group["label"]), "description": str(group["description"]), "color_rgb": list(group["color"]), "rois": list(group["rois"]), } for group in roi_groups ] def draw_roi_legend( draw: ImageDraw.ImageDraw, roi_groups: list[dict[str, object]], x: int, y: int, max_width: int, font, title_font, ) -> int: if not roi_groups: return y title = "ROI borders:" draw.text((x, y), title, fill=(20, 20, 20), font=title_font) title_w, _ = text_size(draw, title, title_font) cursor_x = x + title_w + 18 cursor_y = y + 1 line_h = 24 for group in roi_groups: label = f"{group['label']}: {group['description']}" label_w, _ = text_size(draw, label, font) item_w = 34 + label_w + 22 if cursor_x + item_w > x + max_width and cursor_x > x + title_w + 18: cursor_x = x cursor_y += line_h color = tuple(int(value) for value in group["color"]) line_y = cursor_y + 11 draw.line((cursor_x, line_y, cursor_x + 24, line_y), fill=(0, 0, 0), width=5) draw.line((cursor_x, line_y, cursor_x + 24, line_y), fill=color, width=3) draw.text((cursor_x + 32, cursor_y), label, fill=(20, 20, 20), font=font) cursor_x += item_w return cursor_y + line_h