Spaces:
Paused
Paused
File size: 11,378 Bytes
03e863f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 | """Optional extra brain parcellation support.
Loads volumetric NIfTI parcellation atlases (e.g., Brainnetome, AAL3, Schaefer)
and provides point-in-region queries + on-demand mesh generation via marching cubes.
Dependencies: nibabel, scikit-image (optional — install with:
pip install nibabel scikit-image
)
Usage:
from src.extra_parcellation import ExtraParcellation
extra = ExtraParcellation(
nifti_path="data/extra_parcellation/BN_Atlas_246_1mm.nii.gz",
label_map_path="data/extra_parcellation/BN_Atlas_labels.json" # optional
)
extra.load()
# Query a point in MNI space
result = extra.get_region_at_point(np.array([-30.0, 15.0, 50.0]))
# -> {"label_id": 42, "name": "SFG_R_7_2", "volume_mm3": 1523.0}
# Get VTK mesh for visualization
polydata = extra.get_region_mesh(42)
"""
import json
from pathlib import Path
from typing import Optional
import numpy as np
try:
import nibabel as nib
HAS_NIBABEL = True
except ImportError:
HAS_NIBABEL = False
try:
from skimage.measure import marching_cubes
HAS_SKIMAGE = True
except ImportError:
HAS_SKIMAGE = False
try:
import vtk
from vtkmodules.util.numpy_support import numpy_to_vtk
HAS_VTK = True
except ImportError:
HAS_VTK = False
class ExtraParcellation:
"""Volumetric NIfTI parcellation atlas for additional region granularity.
Designed to work alongside the primary Allen atlas meshes. When both
parcellations cover a point, the caller (FlowMeshOverlay) can decide
which to use (typically the smaller/more specific one).
"""
def __init__(self, nifti_path: Path | str,
label_map_path: Path | str | None = None):
"""
Args:
nifti_path: Path to NIfTI volume (.nii or .nii.gz) with integer labels.
label_map_path: Optional JSON mapping label IDs to human-readable names.
Format: {"1": "Region A", "2": "Region B", ...}
If omitted, labels will be "Region_<id>".
"""
self.nifti_path = Path(nifti_path)
self.label_map_path = Path(label_map_path) if label_map_path else None
# Set after load()
self._data: Optional[np.ndarray] = None # (X,Y,Z) int label volume
self._affine: Optional[np.ndarray] = None # 4x4 voxel-to-world
self._inv_affine: Optional[np.ndarray] = None # 4x4 world-to-voxel
self._voxel_vol: float = 1.0 # mm^3 per voxel
self._label_names: dict[int, str] = {} # label_id -> name
self._label_volumes: dict[int, float] = {} # label_id -> volume in mm^3
self._mesh_cache: dict[int, object] = {} # label_id -> vtkPolyData
self._unique_labels: set[int] = set()
def load(self) -> bool:
"""Load the NIfTI volume and label map.
Returns True on success, False on failure (with printed warning).
"""
if not HAS_NIBABEL:
print("[extra-parcellation] nibabel not installed. "
"Install with: pip install nibabel")
return False
if not self.nifti_path.exists():
print(f"[extra-parcellation] File not found: {self.nifti_path}")
return False
try:
img = nib.load(str(self.nifti_path))
self._data = np.asarray(img.dataobj, dtype=np.int32)
self._affine = img.affine.copy()
self._inv_affine = np.linalg.inv(self._affine)
# Compute voxel volume from affine
vox_sizes = np.sqrt(np.sum(self._affine[:3, :3] ** 2, axis=0))
self._voxel_vol = float(np.prod(vox_sizes))
# Find all unique labels (excluding 0 = background)
self._unique_labels = set(np.unique(self._data)) - {0}
# Pre-compute volumes
for label_id in self._unique_labels:
voxel_count = int(np.sum(self._data == label_id))
self._label_volumes[label_id] = voxel_count * self._voxel_vol
# Load label names
self._label_names = {}
if self.label_map_path and self.label_map_path.exists():
try:
raw = json.loads(self.label_map_path.read_text(encoding="utf-8"))
for k, v in raw.items():
self._label_names[int(k)] = str(v)
except Exception as e:
print(f"[extra-parcellation] Warning: could not load label map: {e}")
# Fill in missing names
for label_id in self._unique_labels:
if label_id not in self._label_names:
self._label_names[label_id] = f"Region_{label_id}"
print(f"[extra-parcellation] Loaded {self.nifti_path.name}: "
f"{len(self._unique_labels)} regions, "
f"voxel size {vox_sizes[0]:.1f}x{vox_sizes[1]:.1f}x{vox_sizes[2]:.1f} mm, "
f"volume shape {self._data.shape}")
return True
except Exception as e:
print(f"[extra-parcellation] Failed to load {self.nifti_path}: {e}")
return False
def is_loaded(self) -> bool:
return self._data is not None
def _world_to_voxel(self, point: np.ndarray) -> np.ndarray:
"""Convert world (MNI) coordinates to voxel indices."""
pt4 = np.array([point[0], point[1], point[2], 1.0])
vox = self._inv_affine @ pt4
return vox[:3]
def get_region_at_point(self, point: np.ndarray) -> Optional[dict]:
"""Look up which region a world-space point falls in.
Args:
point: (3,) array in world/MNI coordinates (mm)
Returns:
dict with {"label_id": int, "name": str, "volume_mm3": float}
or None if point is outside any labeled region.
"""
if self._data is None:
return None
vox = self._world_to_voxel(point)
ix, iy, iz = int(round(vox[0])), int(round(vox[1])), int(round(vox[2]))
# Bounds check
if (ix < 0 or iy < 0 or iz < 0 or
ix >= self._data.shape[0] or
iy >= self._data.shape[1] or
iz >= self._data.shape[2]):
return None
label = int(self._data[ix, iy, iz])
if label == 0:
return None
return {
"label_id": label,
"name": self._label_names.get(label, f"Region_{label}"),
"volume_mm3": self._label_volumes.get(label, 0.0),
}
def get_nearby_region(self, point: np.ndarray, search_radius: int = 2) -> Optional[dict]:
"""Search nearby voxels if exact point has no label.
Args:
point: (3,) world coordinates
search_radius: search cube half-size in voxels
Returns:
dict or None
"""
if self._data is None:
return None
vox = self._world_to_voxel(point)
cx, cy, cz = int(round(vox[0])), int(round(vox[1])), int(round(vox[2]))
best_label = None
best_dist = float('inf')
for dx in range(-search_radius, search_radius + 1):
for dy in range(-search_radius, search_radius + 1):
for dz in range(-search_radius, search_radius + 1):
if dx == 0 and dy == 0 and dz == 0:
continue
ix, iy, iz = cx + dx, cy + dy, cz + dz
if (ix < 0 or iy < 0 or iz < 0 or
ix >= self._data.shape[0] or
iy >= self._data.shape[1] or
iz >= self._data.shape[2]):
continue
label = int(self._data[ix, iy, iz])
if label == 0:
continue
dist = dx*dx + dy*dy + dz*dz
if dist < best_dist:
best_dist = dist
best_label = label
if best_label is None:
return None
return {
"label_id": best_label,
"name": self._label_names.get(best_label, f"Region_{best_label}"),
"volume_mm3": self._label_volumes.get(best_label, 0.0),
}
def get_region_name(self, label_id: int) -> str:
return self._label_names.get(label_id, f"Region_{label_id}")
def get_region_volume(self, label_id: int) -> float:
return self._label_volumes.get(label_id, 0.0)
def get_all_labels(self) -> list[int]:
return sorted(self._unique_labels)
def get_region_mesh(self, label_id: int):
"""Generate a VTK polydata mesh for a region via marching cubes.
Meshes are cached after first generation. Returns None if
scikit-image is not installed or the region has too few voxels.
"""
if label_id in self._mesh_cache:
return self._mesh_cache[label_id]
if not HAS_SKIMAGE:
print("[extra-parcellation] scikit-image not installed for mesh generation. "
"Install with: pip install scikit-image")
return None
if not HAS_VTK:
return None
if self._data is None:
return None
# Create binary mask
mask = (self._data == label_id).astype(np.float32)
if mask.sum() < 10: # too small for meaningful mesh
return None
# Pad to avoid edge artifacts
padded = np.pad(mask, 1, mode='constant', constant_values=0)
try:
verts, faces, normals, _ = marching_cubes(padded, level=0.5)
# Remove padding offset
verts = verts - 1.0
# Transform vertices from voxel to world space
ones = np.ones((len(verts), 1), dtype=np.float64)
verts_h = np.hstack([verts, ones])
world_verts = (self._affine @ verts_h.T).T[:, :3]
# Build VTK polydata
points = vtk.vtkPoints()
points.SetData(numpy_to_vtk(world_verts.astype(np.float64), deep=True))
cells = vtk.vtkCellArray()
for face in faces:
cells.InsertNextCell(3)
for idx in face:
cells.InsertCellPoint(int(idx))
polydata = vtk.vtkPolyData()
polydata.SetPoints(points)
polydata.SetPolys(cells)
# Compute normals for smooth rendering
normal_gen = vtk.vtkPolyDataNormals()
normal_gen.SetInputData(polydata)
normal_gen.ComputePointNormalsOn()
normal_gen.Update()
polydata = normal_gen.GetOutput()
self._mesh_cache[label_id] = polydata
return polydata
except Exception as e:
print(f"[extra-parcellation] Mesh generation failed for label {label_id}: {e}")
return None
def get_region_center(self, label_id: int) -> Optional[np.ndarray]:
"""Get the center of mass of a region in world coordinates."""
if self._data is None:
return None
coords = np.argwhere(self._data == label_id)
if len(coords) == 0:
return None
center_vox = coords.mean(axis=0)
center_world = self._affine @ np.array([*center_vox, 1.0])
return center_world[:3]
|