mmrech commited on
Commit
1c561bc
·
verified ·
1 Parent(s): 5706ce4

Upload segment_neuroimaging.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. segment_neuroimaging.py +1123 -0
segment_neuroimaging.py ADDED
@@ -0,0 +1,1123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NPH Neuroimaging Segmentation Module
3
+ Segmentation and quantitative biomarker analysis for Normal Pressure Hydrocephalus
4
+
5
+ Supports CT Head, MRI T1, T2, FLAIR.
6
+ Computes: Evans' index, callosal angle, temporal horn width, z-Evans index,
7
+ DESH pattern assessment, periventricular hyperintensity scoring.
8
+
9
+ Author: Matheus Rech, MD
10
+ Version: 2.0.0 (NPH-focused)
11
+ """
12
+
13
+ import cv2
14
+ import numpy as np
15
+ from PIL import Image, ImageDraw, ImageFont
16
+ from typing import Dict, List, Tuple, Optional, Union
17
+ from dataclasses import dataclass, field
18
+ from enum import Enum
19
+ import warnings
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Enums and data classes
24
+ # ---------------------------------------------------------------------------
25
+
26
+ class Modality(Enum):
27
+ CT_HEAD = "ct_head"
28
+ T1 = "t1_weighted"
29
+ T1_GD = "t1_gadolinium"
30
+ T2 = "t2_weighted"
31
+ FLAIR = "flair"
32
+
33
+
34
+ class CSFAppearance(Enum):
35
+ """How CSF appears on each modality (needed for correct thresholding)."""
36
+ DARK = "dark" # CT, T1, FLAIR
37
+ BRIGHT = "bright" # T2
38
+
39
+
40
+ @dataclass
41
+ class SegmentationResult:
42
+ """Container for NPH segmentation results."""
43
+ masks: Dict[str, np.ndarray]
44
+ overlay: np.ndarray
45
+ contours: Dict[str, List] = field(default_factory=dict)
46
+ metadata: Dict = field(default_factory=dict)
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # NPH color palette
51
+ # ---------------------------------------------------------------------------
52
+
53
+ COLORS = {
54
+ "lateral_ventricles": (0, 150, 255),
55
+ "ventricles": (0, 150, 255),
56
+ "third_ventricle": (0, 100, 200),
57
+ "temporal_horns": (0, 200, 255),
58
+ "csf": (0, 150, 255),
59
+ "parenchyma": (100, 200, 100),
60
+ "pvh": (255, 200, 0),
61
+ "periventricular_hyperintensity": (255, 200, 0),
62
+ "sylvian_fissures": (200, 100, 255),
63
+ "high_convexity_sulci": (255, 150, 100),
64
+ "skull": (255, 255, 200),
65
+ "bone": (255, 255, 200),
66
+ "aqueductal_flow_void": (255, 80, 80),
67
+ "transependymal_flow": (255, 180, 0),
68
+ "subdural_collection": (180, 60, 60),
69
+ "hemorrhage": (200, 50, 100),
70
+ "white_matter": (180, 180, 140),
71
+ "gray_matter": (140, 160, 140),
72
+ }
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Modality-specific CSF behavior
77
+ # ---------------------------------------------------------------------------
78
+
79
+ CSF_MODE = {
80
+ Modality.CT_HEAD: CSFAppearance.DARK,
81
+ Modality.T1: CSFAppearance.DARK,
82
+ Modality.T1_GD: CSFAppearance.DARK,
83
+ Modality.T2: CSFAppearance.BRIGHT,
84
+ Modality.FLAIR: CSFAppearance.DARK,
85
+ }
86
+
87
+ # Default 8-bit thresholds for ventricle segmentation per modality
88
+ VENTRICLE_THRESHOLDS = {
89
+ Modality.CT_HEAD: {"csf_low": 0, "csf_high": 55}, # Dark on brain window
90
+ Modality.T1: {"csf_low": 0, "csf_high": 45}, # Hypointense
91
+ Modality.T1_GD: {"csf_low": 0, "csf_high": 45},
92
+ Modality.T2: {"csf_low": 170, "csf_high": 255}, # Hyperintense
93
+ Modality.FLAIR: {"csf_low": 0, "csf_high": 50}, # Suppressed
94
+ }
95
+
96
+ # Periventricular hyperintensity thresholds (FLAIR only)
97
+ PVH_THRESHOLD = 145 # Pixel intensity above which = hyperintense on FLAIR
98
+
99
+ # CT Hounsfield windows
100
+ CT_WINDOWS = {
101
+ "brain": (40, 80),
102
+ "subdural": (75, 215),
103
+ "bone": (400, 1800),
104
+ }
105
+
106
+
107
+ # ===========================================================================
108
+ # IMAGE LOADING AND PREPROCESSING
109
+ # ===========================================================================
110
+
111
+ def preprocess_image(image_path: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
112
+ """
113
+ Load and preprocess image.
114
+
115
+ Returns:
116
+ (original_rgb, grayscale, blurred)
117
+ """
118
+ img = cv2.imread(image_path)
119
+ if img is None:
120
+ raise FileNotFoundError(f"Could not read image: {image_path}")
121
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
122
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
123
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
124
+ return img_rgb, gray, blurred
125
+
126
+
127
+ def apply_ct_window(hu_image: np.ndarray, center: float, width: float) -> np.ndarray:
128
+ """Apply CT windowing: HU values to 8-bit grayscale."""
129
+ low = center - width / 2.0
130
+ high = center + width / 2.0
131
+ windowed = np.clip(hu_image, low, high)
132
+ return ((windowed - low) / (high - low) * 255.0).astype(np.uint8)
133
+
134
+
135
+ def load_dicom(filepath: str) -> Tuple[np.ndarray, dict]:
136
+ """
137
+ Load DICOM and return HU image + metadata.
138
+ Requires pydicom.
139
+ """
140
+ try:
141
+ import pydicom
142
+ except ImportError:
143
+ raise ImportError("pydicom required for DICOM. Install: pip install pydicom")
144
+
145
+ ds = pydicom.dcmread(filepath)
146
+ pixel_array = ds.pixel_array.astype(np.float64)
147
+ slope = float(getattr(ds, "RescaleSlope", 1))
148
+ intercept = float(getattr(ds, "RescaleIntercept", 0))
149
+ hu = (pixel_array * slope + intercept).astype(np.int16)
150
+
151
+ spacing = list(getattr(ds, "PixelSpacing", [1.0, 1.0]))
152
+ meta = {
153
+ "patient_id": str(getattr(ds, "PatientID", "")),
154
+ "modality": str(getattr(ds, "Modality", "")),
155
+ "series_description": str(getattr(ds, "SeriesDescription", "")),
156
+ "slice_thickness": float(getattr(ds, "SliceThickness", 0)),
157
+ "pixel_spacing_mm": [float(s) for s in spacing],
158
+ "rows": int(ds.Rows),
159
+ "columns": int(ds.Columns),
160
+ }
161
+ return hu, meta
162
+
163
+
164
+ # ===========================================================================
165
+ # CORE SEGMENTATION PRIMITIVES
166
+ # ===========================================================================
167
+
168
+ def create_roi_mask(blurred: np.ndarray, threshold: int = 15) -> np.ndarray:
169
+ """Create ROI mask excluding background."""
170
+ _, roi = cv2.threshold(blurred, threshold, 255, cv2.THRESH_BINARY)
171
+ kernel = np.ones((10, 10), np.uint8)
172
+ roi = cv2.morphologyEx(roi, cv2.MORPH_CLOSE, kernel, iterations=3)
173
+ return roi
174
+
175
+
176
+ def morphological_cleanup(
177
+ mask: np.ndarray,
178
+ kernel_size: int = 5,
179
+ close_iter: int = 2,
180
+ open_iter: int = 2,
181
+ ) -> np.ndarray:
182
+ """Morphological close then open."""
183
+ kernel = np.ones((kernel_size, kernel_size), np.uint8)
184
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=close_iter)
185
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=open_iter)
186
+ return mask
187
+
188
+
189
+ def filter_by_area(mask: np.ndarray, min_area: int = 300) -> np.ndarray:
190
+ """Remove small connected components."""
191
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
192
+ filtered = np.zeros_like(mask)
193
+ for cnt in contours:
194
+ if cv2.contourArea(cnt) > min_area:
195
+ cv2.drawContours(filtered, [cnt], -1, 255, -1)
196
+ return filtered
197
+
198
+
199
+ def segment_adaptive(
200
+ gray: np.ndarray,
201
+ block_size: int = 51,
202
+ C: int = 10,
203
+ roi_mask: Optional[np.ndarray] = None,
204
+ ) -> np.ndarray:
205
+ """Adaptive thresholding for field inhomogeneity."""
206
+ if block_size % 2 == 0:
207
+ block_size += 1
208
+ mask = cv2.adaptiveThreshold(
209
+ gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, block_size, C
210
+ )
211
+ if roi_mask is not None:
212
+ mask = cv2.bitwise_and(mask, roi_mask)
213
+ return mask
214
+
215
+
216
+ def segment_otsu(
217
+ gray: np.ndarray,
218
+ roi_mask: Optional[np.ndarray] = None,
219
+ ) -> Tuple[np.ndarray, float]:
220
+ """Otsu automatic thresholding. Returns (mask, threshold_value)."""
221
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
222
+ val, mask = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
223
+ if roi_mask is not None:
224
+ mask = cv2.bitwise_and(mask, roi_mask)
225
+ return mask, float(val)
226
+
227
+
228
+ def region_growing(
229
+ gray: np.ndarray,
230
+ seed: Tuple[int, int],
231
+ tolerance: int = 15,
232
+ roi_mask: Optional[np.ndarray] = None,
233
+ ) -> np.ndarray:
234
+ """Region growing from seed point within intensity tolerance."""
235
+ h, w = gray.shape[:2]
236
+ sx, sy = seed
237
+ seed_val = int(gray[sy, sx])
238
+ low, high = max(0, seed_val - tolerance), min(255, seed_val + tolerance)
239
+
240
+ visited = np.zeros((h, w), dtype=np.uint8)
241
+ mask = np.zeros((h, w), dtype=np.uint8)
242
+ neighbors = [(-1, -1), (-1, 0), (-1, 1), (0, -1),
243
+ (0, 1), (1, -1), (1, 0), (1, 1)]
244
+
245
+ stack = [(sx, sy)]
246
+ visited[sy, sx] = 1
247
+
248
+ while stack:
249
+ cx, cy = stack.pop()
250
+ val = int(gray[cy, cx])
251
+ if low <= val <= high:
252
+ if roi_mask is not None and roi_mask[cy, cx] == 0:
253
+ continue
254
+ mask[cy, cx] = 255
255
+ for dx, dy in neighbors:
256
+ nx, ny = cx + dx, cy + dy
257
+ if 0 <= nx < w and 0 <= ny < h and visited[ny, nx] == 0:
258
+ visited[ny, nx] = 1
259
+ stack.append((nx, ny))
260
+ return mask
261
+
262
+
263
+ def watershed_segment(
264
+ gray: np.ndarray,
265
+ roi_mask: Optional[np.ndarray] = None,
266
+ min_distance: int = 20,
267
+ threshold_ratio: float = 0.5,
268
+ ) -> Tuple[np.ndarray, int]:
269
+ """Watershed segmentation. Returns (label_image, num_labels)."""
270
+ if roi_mask is None:
271
+ _, roi_mask = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
272
+ dist = cv2.distanceTransform(roi_mask, cv2.DIST_L2, 5)
273
+ _, sure_fg = cv2.threshold(dist, threshold_ratio * dist.max(), 255, 0)
274
+ sure_fg = sure_fg.astype(np.uint8)
275
+ kernel = np.ones((3, 3), np.uint8)
276
+ sure_bg = cv2.dilate(roi_mask, kernel, iterations=3)
277
+ unknown = cv2.subtract(sure_bg, sure_fg)
278
+ num_labels, markers = cv2.connectedComponents(sure_fg)
279
+ markers = markers + 1
280
+ markers[unknown == 255] = 0
281
+ img_color = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
282
+ markers = cv2.watershed(img_color, markers)
283
+ return markers, num_labels
284
+
285
+
286
+ def detect_edges_canny(
287
+ gray: np.ndarray, low: int = 50, high: int = 150,
288
+ roi_mask: Optional[np.ndarray] = None,
289
+ ) -> np.ndarray:
290
+ """Canny edge detection."""
291
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
292
+ edges = cv2.Canny(blurred, low, high)
293
+ if roi_mask is not None:
294
+ edges = cv2.bitwise_and(edges, roi_mask)
295
+ return edges
296
+
297
+
298
+ def detect_edges_sobel(
299
+ gray: np.ndarray, ksize: int = 3,
300
+ roi_mask: Optional[np.ndarray] = None,
301
+ ) -> np.ndarray:
302
+ """Sobel gradient magnitude (normalized to uint8)."""
303
+ blur = cv2.GaussianBlur(gray, (5, 5), 0)
304
+ gx = cv2.Sobel(blur, cv2.CV_64F, 1, 0, ksize=ksize)
305
+ gy = cv2.Sobel(blur, cv2.CV_64F, 0, 1, ksize=ksize)
306
+ mag = np.sqrt(gx ** 2 + gy ** 2)
307
+ mag = (mag / mag.max() * 255).astype(np.uint8) if mag.max() > 0 else mag.astype(np.uint8)
308
+ if roi_mask is not None:
309
+ mag = cv2.bitwise_and(mag, mag, mask=roi_mask)
310
+ return mag
311
+
312
+
313
+ # ===========================================================================
314
+ # VENTRICLE SEGMENTATION
315
+ # ===========================================================================
316
+
317
+ def segment_ventricles(
318
+ gray: np.ndarray,
319
+ modality: Modality,
320
+ roi_mask: Optional[np.ndarray] = None,
321
+ custom_thresholds: Optional[Dict] = None,
322
+ ) -> np.ndarray:
323
+ """
324
+ Segment ventricular CSF on any supported modality.
325
+
326
+ Args:
327
+ gray: Grayscale image (uint8)
328
+ modality: Imaging modality
329
+ roi_mask: Optional brain ROI mask
330
+ custom_thresholds: Optional dict with 'csf_low' and 'csf_high'
331
+
332
+ Returns:
333
+ Binary ventricle mask (uint8, 0 or 255)
334
+ """
335
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
336
+ if roi_mask is None:
337
+ roi_mask = create_roi_mask(blurred, threshold=15)
338
+
339
+ thresh = custom_thresholds or VENTRICLE_THRESHOLDS[modality]
340
+ csf_low = thresh["csf_low"]
341
+ csf_high = thresh["csf_high"]
342
+
343
+ csf_mode = CSF_MODE[modality]
344
+
345
+ if csf_mode == CSFAppearance.DARK:
346
+ # CSF is dark: threshold for low intensities
347
+ mask = cv2.inRange(blurred, csf_low, csf_high)
348
+ else:
349
+ # CSF is bright (T2): threshold for high intensities
350
+ mask = cv2.inRange(blurred, csf_low, csf_high)
351
+
352
+ mask = cv2.bitwise_and(mask, roi_mask)
353
+ mask = morphological_cleanup(mask, kernel_size=5, close_iter=3, open_iter=2)
354
+ mask = filter_by_area(mask, min_area=300)
355
+ return mask
356
+
357
+
358
+ def segment_skull(
359
+ gray: np.ndarray,
360
+ threshold: int = 200,
361
+ ) -> np.ndarray:
362
+ """
363
+ Segment inner skull boundary for diameter measurement.
364
+ For CT (bright bone) or any modality with visible skull.
365
+ """
366
+ _, bone_mask = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY)
367
+ bone_mask = morphological_cleanup(bone_mask, kernel_size=7, close_iter=3, open_iter=1)
368
+ bone_mask = filter_by_area(bone_mask, min_area=1000)
369
+ return bone_mask
370
+
371
+
372
+ # ===========================================================================
373
+ # NPH BIOMARKER COMPUTATIONS
374
+ # ===========================================================================
375
+
376
+ def compute_evans_index(
377
+ ventricle_mask: np.ndarray,
378
+ skull_mask: Optional[np.ndarray] = None,
379
+ image_width: Optional[int] = None,
380
+ pixel_spacing_mm: Optional[float] = None,
381
+ ) -> Dict:
382
+ """
383
+ Compute Evans' Index from ventricle and skull masks.
384
+
385
+ If skull_mask is not available, uses image_width as proxy for
386
+ maximum skull diameter (less accurate).
387
+
388
+ Args:
389
+ ventricle_mask: Binary ventricle mask (uint8)
390
+ skull_mask: Optional binary skull mask
391
+ image_width: Fallback for skull diameter (image pixel width)
392
+ pixel_spacing_mm: Optional pixel spacing for mm conversion
393
+
394
+ Returns:
395
+ Dict with 'evans_index', 'frontal_horn_width_px',
396
+ 'skull_diameter_px', and optionally '_mm' variants
397
+ """
398
+ h, w = ventricle_mask.shape[:2]
399
+
400
+ # Find the row with maximum horizontal ventricle extent
401
+ # (approximates the axial level of max frontal horn width)
402
+ max_frontal_width = 0
403
+ max_row = 0
404
+
405
+ for row in range(h):
406
+ cols = np.where(ventricle_mask[row, :] > 0)[0]
407
+ if len(cols) > 0:
408
+ width = cols[-1] - cols[0]
409
+ if width > max_frontal_width:
410
+ max_frontal_width = width
411
+ max_row = row
412
+
413
+ # Skull diameter
414
+ if skull_mask is not None:
415
+ # Find max horizontal extent of non-skull (inner diameter) at the same row range
416
+ # Use the skull mask to find inner table boundaries
417
+ skull_row = skull_mask[max_row, :]
418
+ skull_cols = np.where(skull_row > 0)[0]
419
+ if len(skull_cols) > 1:
420
+ skull_diameter = skull_cols[-1] - skull_cols[0]
421
+ else:
422
+ skull_diameter = image_width or w
423
+ else:
424
+ # Fallback: use the brain ROI width or image width
425
+ skull_diameter = image_width or w
426
+
427
+ if skull_diameter == 0:
428
+ skull_diameter = w
429
+
430
+ evans_index = max_frontal_width / skull_diameter
431
+
432
+ result = {
433
+ "evans_index": round(evans_index, 4),
434
+ "frontal_horn_width_px": max_frontal_width,
435
+ "skull_diameter_px": skull_diameter,
436
+ "measurement_row": max_row,
437
+ }
438
+
439
+ if pixel_spacing_mm is not None:
440
+ result["frontal_horn_width_mm"] = round(max_frontal_width * pixel_spacing_mm, 2)
441
+ result["skull_diameter_mm"] = round(skull_diameter * pixel_spacing_mm, 2)
442
+
443
+ return result
444
+
445
+
446
+ def compute_callosal_angle(
447
+ ventricle_mask: np.ndarray,
448
+ ) -> Dict:
449
+ """
450
+ Estimate callosal angle from a coronal ventricle mask.
451
+
452
+ Finds the two lateral ventricle peaks and the midline apex,
453
+ then computes the angle between the two roof lines.
454
+
455
+ Args:
456
+ ventricle_mask: Binary ventricle mask on a coronal slice (uint8)
457
+
458
+ Returns:
459
+ Dict with 'callosal_angle_deg', 'apex_point', 'left_point', 'right_point'
460
+ """
461
+ h, w = ventricle_mask.shape[:2]
462
+ midline_x = w // 2
463
+
464
+ # Find the topmost ventricle row (highest point of ventricles)
465
+ rows_with_csf = np.where(ventricle_mask.any(axis=1))[0]
466
+ if len(rows_with_csf) == 0:
467
+ return {"callosal_angle_deg": None, "error": "No ventricles detected"}
468
+
469
+ top_row = rows_with_csf[0]
470
+
471
+ # Find apex: topmost point near midline
472
+ midline_band = ventricle_mask[:, max(0, midline_x - 20):min(w, midline_x + 20)]
473
+ midline_rows = np.where(midline_band.any(axis=1))[0]
474
+ if len(midline_rows) == 0:
475
+ # No midline CSF -- use topmost row
476
+ apex_y = top_row
477
+ apex_x = midline_x
478
+ else:
479
+ apex_y = midline_rows[0]
480
+ apex_col_in_band = np.where(midline_band[apex_y, :] > 0)[0]
481
+ apex_x = midline_x - 20 + int(np.mean(apex_col_in_band))
482
+
483
+ # Find the topmost point of left ventricle (left of midline)
484
+ left_mask = ventricle_mask[:, :midline_x]
485
+ left_rows = np.where(left_mask.any(axis=1))[0]
486
+ if len(left_rows) == 0:
487
+ return {"callosal_angle_deg": None, "error": "Left ventricle not detected"}
488
+ left_top_row = left_rows[0]
489
+ left_cols = np.where(left_mask[left_top_row, :] > 0)[0]
490
+ left_x = int(np.mean(left_cols))
491
+ left_y = left_top_row
492
+
493
+ # Find the topmost point of right ventricle (right of midline)
494
+ right_mask = ventricle_mask[:, midline_x:]
495
+ right_rows = np.where(right_mask.any(axis=1))[0]
496
+ if len(right_rows) == 0:
497
+ return {"callosal_angle_deg": None, "error": "Right ventricle not detected"}
498
+ right_top_row = right_rows[0]
499
+ right_cols = np.where(right_mask[right_top_row, :] > 0)[0]
500
+ right_x = midline_x + int(np.mean(right_cols))
501
+ right_y = right_top_row
502
+
503
+ # Compute angle at apex between the two lines
504
+ vec_left = np.array([left_x - apex_x, left_y - apex_y], dtype=float)
505
+ vec_right = np.array([right_x - apex_x, right_y - apex_y], dtype=float)
506
+
507
+ norm_left = np.linalg.norm(vec_left)
508
+ norm_right = np.linalg.norm(vec_right)
509
+
510
+ if norm_left == 0 or norm_right == 0:
511
+ return {"callosal_angle_deg": None, "error": "Degenerate geometry"}
512
+
513
+ cos_angle = np.dot(vec_left, vec_right) / (norm_left * norm_right)
514
+ cos_angle = np.clip(cos_angle, -1.0, 1.0)
515
+ angle_deg = np.degrees(np.arccos(cos_angle))
516
+
517
+ return {
518
+ "callosal_angle_deg": round(angle_deg, 1),
519
+ "apex_point": (apex_x, apex_y),
520
+ "left_point": (left_x, left_y),
521
+ "right_point": (right_x, right_y),
522
+ }
523
+
524
+
525
+ def compute_temporal_horn_width(
526
+ ventricle_mask: np.ndarray,
527
+ pixel_spacing_mm: Optional[float] = None,
528
+ ) -> Dict:
529
+ """
530
+ Estimate temporal horn width from an axial ventricle mask.
531
+
532
+ Looks for ventricle regions in the lower third of the image
533
+ (approximate temporal horn location).
534
+
535
+ Returns:
536
+ Dict with 'temporal_horn_width_px' (and '_mm' if spacing given)
537
+ """
538
+ h, w = ventricle_mask.shape[:2]
539
+
540
+ # Temporal horns are in the lower portion of an axial slice
541
+ lower_third = ventricle_mask[int(h * 0.55):int(h * 0.85), :]
542
+
543
+ max_width = 0
544
+ for row in range(lower_third.shape[0]):
545
+ cols = np.where(lower_third[row, :] > 0)[0]
546
+ if len(cols) > 0:
547
+ # Look for small isolated clusters (temporal horns are smaller)
548
+ # Split into left/right halves
549
+ left_cols = cols[cols < w // 2]
550
+ right_cols = cols[cols >= w // 2]
551
+
552
+ for cluster in [left_cols, right_cols]:
553
+ if len(cluster) > 0:
554
+ cluster_width = cluster[-1] - cluster[0]
555
+ if 3 < cluster_width < w // 4: # reasonable temporal horn size
556
+ max_width = max(max_width, cluster_width)
557
+
558
+ result = {"temporal_horn_width_px": max_width}
559
+ if pixel_spacing_mm is not None:
560
+ result["temporal_horn_width_mm"] = round(max_width * pixel_spacing_mm, 2)
561
+ return result
562
+
563
+
564
+ def compute_third_ventricle_width(
565
+ ventricle_mask: np.ndarray,
566
+ pixel_spacing_mm: Optional[float] = None,
567
+ ) -> Dict:
568
+ """
569
+ Estimate third ventricle width from an axial ventricle mask.
570
+
571
+ Looks for a narrow midline CSF structure.
572
+
573
+ Returns:
574
+ Dict with 'third_ventricle_width_px' (and '_mm' if spacing given)
575
+ """
576
+ h, w = ventricle_mask.shape[:2]
577
+ midline_band = ventricle_mask[:, max(0, w // 2 - 30):min(w, w // 2 + 30)]
578
+
579
+ max_width = 0
580
+ for row in range(midline_band.shape[0]):
581
+ cols = np.where(midline_band[row, :] > 0)[0]
582
+ if len(cols) > 0:
583
+ width = cols[-1] - cols[0]
584
+ if 2 < width < 60: # reasonable third ventricle size
585
+ max_width = max(max_width, width)
586
+
587
+ result = {"third_ventricle_width_px": max_width}
588
+ if pixel_spacing_mm is not None:
589
+ result["third_ventricle_width_mm"] = round(max_width * pixel_spacing_mm, 2)
590
+ return result
591
+
592
+
593
+ def score_pvh(
594
+ flair_gray: np.ndarray,
595
+ ventricle_mask: np.ndarray,
596
+ dilation_px: int = 15,
597
+ pvh_threshold: int = PVH_THRESHOLD,
598
+ ) -> Dict:
599
+ """
600
+ Score periventricular hyperintensity on FLAIR.
601
+
602
+ Creates a periventricular zone by dilating the ventricle mask,
603
+ then measures hyperintense signal within that zone.
604
+
605
+ Args:
606
+ flair_gray: FLAIR grayscale image (uint8)
607
+ ventricle_mask: Binary ventricle mask
608
+ dilation_px: Size of periventricular zone in pixels
609
+ pvh_threshold: Intensity threshold for hyperintensity
610
+
611
+ Returns:
612
+ Dict with 'pvh_grade' (0-3), 'pvh_ratio', 'pvh_area_px'
613
+ """
614
+ kernel = np.ones((dilation_px, dilation_px), np.uint8)
615
+ periventricular_zone = cv2.dilate(ventricle_mask, kernel, iterations=1)
616
+ periventricular_zone = cv2.subtract(periventricular_zone, ventricle_mask)
617
+
618
+ # Measure hyperintensity within periventricular zone
619
+ pvh_mask = cv2.inRange(flair_gray, pvh_threshold, 255)
620
+ pvh_in_zone = cv2.bitwise_and(pvh_mask, periventricular_zone)
621
+
622
+ zone_area = (periventricular_zone > 0).sum()
623
+ pvh_area = (pvh_in_zone > 0).sum()
624
+ pvh_ratio = pvh_area / zone_area if zone_area > 0 else 0.0
625
+
626
+ # Grade (Fazekas-like for NPH context)
627
+ if pvh_ratio < 0.05:
628
+ grade = 0 # No significant PVH
629
+ elif pvh_ratio < 0.15:
630
+ grade = 1 # Pencil-thin rim
631
+ elif pvh_ratio < 0.35:
632
+ grade = 2 # Smooth halo
633
+ else:
634
+ grade = 3 # Irregular, extending into deep white matter
635
+
636
+ return {
637
+ "pvh_grade": grade,
638
+ "pvh_ratio": round(pvh_ratio, 4),
639
+ "pvh_area_px": int(pvh_area),
640
+ "periventricular_zone_area_px": int(zone_area),
641
+ "pvh_mask": pvh_in_zone,
642
+ }
643
+
644
+
645
+ def assess_desh(
646
+ ventricle_mask: np.ndarray,
647
+ gray: np.ndarray,
648
+ roi_mask: np.ndarray,
649
+ modality: Modality,
650
+ pixel_spacing_mm: Optional[float] = None,
651
+ ) -> Dict:
652
+ """
653
+ Assess DESH (Disproportionately Enlarged Subarachnoid-space Hydrocephalus) pattern.
654
+
655
+ Compares sylvian fissure CSF to high-convexity sulcal CSF.
656
+ DESH-positive = enlarged sylvian fissures + tight high convexity + ventriculomegaly.
657
+
658
+ Args:
659
+ ventricle_mask: Binary ventricle mask
660
+ gray: Grayscale image
661
+ roi_mask: Brain ROI mask
662
+ modality: Imaging modality
663
+ pixel_spacing_mm: Optional pixel spacing
664
+
665
+ Returns:
666
+ Dict with DESH component scores and overall assessment
667
+ """
668
+ h, w = gray.shape[:2]
669
+
670
+ # Evans' index component
671
+ ei_data = compute_evans_index(ventricle_mask, image_width=w, pixel_spacing_mm=pixel_spacing_mm)
672
+ ei = ei_data["evans_index"]
673
+
674
+ # Ventriculomegaly score
675
+ if ei < 0.3:
676
+ vm_score = 0
677
+ elif ei <= 0.33:
678
+ vm_score = 1
679
+ else:
680
+ vm_score = 2
681
+
682
+ # Segment all CSF (sulcal + ventricular)
683
+ thresh = VENTRICLE_THRESHOLDS[modality]
684
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
685
+ all_csf = cv2.inRange(blurred, thresh["csf_low"], thresh["csf_high"])
686
+ all_csf = cv2.bitwise_and(all_csf, roi_mask)
687
+
688
+ # Non-ventricular CSF = sulcal CSF
689
+ sulcal_csf = cv2.subtract(all_csf, ventricle_mask)
690
+ sulcal_csf = morphological_cleanup(sulcal_csf, kernel_size=3, close_iter=1, open_iter=1)
691
+
692
+ # Sylvian fissure region (middle third vertically, lateral portions)
693
+ sylvian_region = np.zeros_like(gray, dtype=np.uint8)
694
+ y_start, y_end = int(h * 0.35), int(h * 0.65)
695
+ x_left_end, x_right_start = int(w * 0.15), int(w * 0.85)
696
+ sylvian_region[y_start:y_end, :x_left_end] = 255
697
+ sylvian_region[y_start:y_end, x_right_start:] = 255
698
+ # Also include lateral middle zones
699
+ sylvian_region[y_start:y_end, :int(w * 0.3)] = 255
700
+ sylvian_region[y_start:y_end, int(w * 0.7):] = 255
701
+
702
+ sylvian_csf = cv2.bitwise_and(sulcal_csf, sylvian_region)
703
+ sylvian_csf_area = (sylvian_csf > 0).sum()
704
+
705
+ # High convexity region (top 25% of image)
706
+ convexity_region = np.zeros_like(gray, dtype=np.uint8)
707
+ convexity_region[:int(h * 0.25), :] = 255
708
+ convexity_csf = cv2.bitwise_and(sulcal_csf, convexity_region)
709
+ convexity_csf_area = (convexity_csf > 0).sum()
710
+
711
+ # Sylvian/convexity ratio
712
+ if convexity_csf_area > 0:
713
+ ratio = sylvian_csf_area / convexity_csf_area
714
+ else:
715
+ ratio = float("inf") if sylvian_csf_area > 0 else 0.0
716
+
717
+ # Sylvian fissure score
718
+ if ratio < 1.5:
719
+ sylvian_score = 0 # Proportionate
720
+ elif ratio < 3.0:
721
+ sylvian_score = 1 # Mildly disproportionate
722
+ else:
723
+ sylvian_score = 2 # Markedly disproportionate (DESH pattern)
724
+
725
+ # Convexity tightness score
726
+ brain_top_area = (roi_mask[:int(h * 0.25), :] > 0).sum()
727
+ convexity_ratio = convexity_csf_area / brain_top_area if brain_top_area > 0 else 0
728
+ if convexity_ratio > 0.1:
729
+ convexity_score = 0 # Normal sulci
730
+ elif convexity_ratio > 0.04:
731
+ convexity_score = 1 # Mildly tight
732
+ else:
733
+ convexity_score = 2 # Effaced (tight high convexity)
734
+
735
+ # DESH positive if all components >= 2 (or total >= 5 as softer criterion)
736
+ total_score = vm_score + sylvian_score + convexity_score
737
+ is_desh_positive = (vm_score >= 1 and sylvian_score >= 2 and convexity_score >= 2)
738
+
739
+ return {
740
+ "is_desh_positive": is_desh_positive,
741
+ "total_score": total_score,
742
+ "ventriculomegaly_score": vm_score,
743
+ "sylvian_dilation_score": sylvian_score,
744
+ "convexity_tightness_score": convexity_score,
745
+ "evans_index": ei,
746
+ "sylvian_convexity_ratio": round(ratio, 2) if ratio != float("inf") else "inf",
747
+ "sylvian_csf_area_px": int(sylvian_csf_area),
748
+ "convexity_csf_area_px": int(convexity_csf_area),
749
+ "sylvian_mask": sylvian_csf,
750
+ "convexity_mask": convexity_csf,
751
+ }
752
+
753
+
754
+ # ===========================================================================
755
+ # FOUNDATION MODEL WRAPPERS
756
+ # ===========================================================================
757
+
758
+ def sam_segment(
759
+ image: np.ndarray,
760
+ points: List[Tuple[int, int]],
761
+ labels: List[int],
762
+ checkpoint: str = "sam_vit_h.pth",
763
+ model_type: str = "vit_h",
764
+ ) -> np.ndarray:
765
+ """SAM point-prompt segmentation. Requires segment-anything."""
766
+ try:
767
+ from segment_anything import SamPredictor, sam_model_registry
768
+ except ImportError:
769
+ raise ImportError("segment-anything required. See: https://github.com/facebookresearch/segment-anything")
770
+ sam = sam_model_registry[model_type](checkpoint=checkpoint)
771
+ predictor = SamPredictor(sam)
772
+ predictor.set_image(image)
773
+ masks, scores, _ = predictor.predict(
774
+ point_coords=np.array(points), point_labels=np.array(labels), multimask_output=True
775
+ )
776
+ return (masks[np.argmax(scores)].astype(np.uint8) * 255)
777
+
778
+
779
+ def medsam_segment(
780
+ image: np.ndarray,
781
+ bbox: Tuple[int, int, int, int],
782
+ checkpoint: Optional[str] = None,
783
+ ) -> np.ndarray:
784
+ """MedSAM bbox segmentation. Requires medsam."""
785
+ try:
786
+ from medsam import MedSAMPredictor
787
+ except ImportError:
788
+ raise ImportError("MedSAM required. See: https://github.com/bowang-lab/MedSAM")
789
+ predictor = MedSAMPredictor(checkpoint=checkpoint)
790
+ return (predictor.predict(image, bbox).astype(np.uint8) * 255)
791
+
792
+
793
+ # ===========================================================================
794
+ # VISUALIZATION
795
+ # ===========================================================================
796
+
797
+ def create_overlay(
798
+ img_rgb: np.ndarray,
799
+ masks: Dict[str, np.ndarray],
800
+ alpha: float = 0.45,
801
+ draw_contours: bool = True,
802
+ ) -> np.ndarray:
803
+ """Create color overlay visualization."""
804
+ overlay = img_rgb.copy()
805
+ for name, mask in masks.items():
806
+ color = COLORS.get(name, (200, 200, 200))
807
+ overlay[mask > 0] = color
808
+
809
+ any_mask = np.zeros(img_rgb.shape[:2], dtype=np.uint8)
810
+ for mask in masks.values():
811
+ any_mask = cv2.bitwise_or(any_mask, mask)
812
+
813
+ result = img_rgb.copy()
814
+ for c in range(3):
815
+ result[:, :, c] = np.where(
816
+ any_mask > 0,
817
+ (alpha * overlay[:, :, c] + (1 - alpha) * img_rgb[:, :, c]).astype(np.uint8),
818
+ img_rgb[:, :, c],
819
+ )
820
+
821
+ if draw_contours:
822
+ for name, mask in masks.items():
823
+ color = COLORS.get(name, (200, 200, 200))
824
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
825
+ for cnt in contours:
826
+ if cv2.contourArea(cnt) > 100:
827
+ cv2.drawContours(result, [cnt], -1, color, 2)
828
+ return result
829
+
830
+
831
+ def add_annotations(
832
+ image: np.ndarray,
833
+ masks: Dict[str, np.ndarray],
834
+ title: str = "NPH Segmentation",
835
+ biomarkers: Optional[Dict] = None,
836
+ ) -> np.ndarray:
837
+ """Add title, legend, and optionally biomarker values to image."""
838
+ pil_img = Image.fromarray(image)
839
+ draw = ImageDraw.Draw(pil_img)
840
+ height, width = image.shape[:2]
841
+
842
+ try:
843
+ font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18)
844
+ font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
845
+ font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
846
+ except Exception:
847
+ font_title = font_label = font_small = ImageFont.load_default()
848
+
849
+ # Title
850
+ draw.text((width // 2 - 80, 8), title, fill=(255, 255, 255), font=font_title)
851
+
852
+ # Legend (bottom-left)
853
+ num_items = len(masks)
854
+ legend_x, legend_y = 12, height - 28 * num_items - 20
855
+ box_height = 28 * num_items + 12
856
+ draw.rectangle(
857
+ [(legend_x, legend_y), (legend_x + 180, legend_y + box_height)],
858
+ fill=(0, 0, 0, 180), outline=(150, 150, 150),
859
+ )
860
+ y = legend_y + 8
861
+ for name in masks.keys():
862
+ color = COLORS.get(name, (200, 200, 200))
863
+ draw.rectangle([(legend_x + 8, y), (legend_x + 24, y + 14)], fill=color, outline=(200, 200, 200))
864
+ draw.text((legend_x + 32, y - 1), name.replace("_", " ").title(), fill=(255, 255, 255), font=font_label)
865
+ y += 24
866
+
867
+ # Biomarker panel (top-right)
868
+ if biomarkers:
869
+ bm_x = width - 220
870
+ bm_y = 35
871
+ bm_items = []
872
+ if "evans_index" in biomarkers:
873
+ ei = biomarkers["evans_index"]
874
+ status = "ABNORMAL" if ei > 0.3 else "normal"
875
+ bm_items.append(f"Evans' Index: {ei:.3f} ({status})")
876
+ if "callosal_angle_deg" in biomarkers and biomarkers["callosal_angle_deg"] is not None:
877
+ ca = biomarkers["callosal_angle_deg"]
878
+ bm_items.append(f"Callosal Angle: {ca:.1f} deg")
879
+ if "temporal_horn_width_px" in biomarkers:
880
+ thw = biomarkers["temporal_horn_width_px"]
881
+ bm_items.append(f"Temporal Horn: {thw} px")
882
+ if "pvh_grade" in biomarkers:
883
+ bm_items.append(f"PVH Grade: {biomarkers['pvh_grade']}/3")
884
+ if "is_desh_positive" in biomarkers:
885
+ desh = "POSITIVE" if biomarkers["is_desh_positive"] else "negative"
886
+ bm_items.append(f"DESH: {desh}")
887
+
888
+ if bm_items:
889
+ box_h = 20 * len(bm_items) + 12
890
+ draw.rectangle(
891
+ [(bm_x - 5, bm_y - 5), (bm_x + 210, bm_y + box_h)],
892
+ fill=(0, 0, 0, 200), outline=(100, 200, 255),
893
+ )
894
+ for i, text in enumerate(bm_items):
895
+ draw.text((bm_x + 3, bm_y + 3 + i * 20), text, fill=(220, 240, 255), font=font_small)
896
+
897
+ return np.array(pil_img)
898
+
899
+
900
+ def create_comparison(
901
+ original: np.ndarray,
902
+ segmented: np.ndarray,
903
+ title: str = "Original vs NPH Segmentation",
904
+ ) -> np.ndarray:
905
+ """Side-by-side comparison."""
906
+ height, width = original.shape[:2]
907
+ gap = 20
908
+ comp_width = width * 2 + gap
909
+ comp_height = height + 60
910
+ comp = np.zeros((comp_height, comp_width, 3), dtype=np.uint8)
911
+ comp[:] = (25, 25, 30)
912
+ comp[55:55 + height, :width] = original
913
+ comp[55:55 + height, width + gap:] = segmented
914
+
915
+ pil = Image.fromarray(comp)
916
+ draw = ImageDraw.Draw(pil)
917
+ try:
918
+ font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18)
919
+ except Exception:
920
+ font = ImageFont.load_default()
921
+ draw.text((width // 2 - 30, 20), "Original", fill=(200, 200, 200), font=font)
922
+ draw.text((width + gap + width // 2 - 60, 20), "NPH Analysis", fill=(200, 200, 200), font=font)
923
+ draw.text((comp_width // 2 - 100, 2), title, fill=(255, 255, 255), font=font)
924
+ return np.array(pil)
925
+
926
+
927
+ # ===========================================================================
928
+ # QUALITY METRICS
929
+ # ===========================================================================
930
+
931
+ def dice_coefficient(pred: np.ndarray, gt: np.ndarray) -> float:
932
+ """Dice coefficient between prediction and ground truth."""
933
+ p, g = (pred > 0).astype(bool), (gt > 0).astype(bool)
934
+ inter = np.sum(p & g)
935
+ total = np.sum(p) + np.sum(g)
936
+ return 2.0 * inter / total if total > 0 else 1.0
937
+
938
+
939
+ def iou_score(pred: np.ndarray, gt: np.ndarray) -> float:
940
+ """Intersection over Union (Jaccard index)."""
941
+ p, g = (pred > 0).astype(bool), (gt > 0).astype(bool)
942
+ inter = np.sum(p & g)
943
+ union = np.sum(p | g)
944
+ return float(inter) / float(union) if union > 0 else 1.0
945
+
946
+
947
+ # ===========================================================================
948
+ # INTERNAL HELPERS
949
+ # ===========================================================================
950
+
951
+ def _extract_contours(masks: Dict[str, np.ndarray], min_area: int = 200) -> Dict[str, List]:
952
+ out = {}
953
+ for name, mask in masks.items():
954
+ cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
955
+ out[name] = [cnt.tolist() for cnt in cnts if cv2.contourArea(cnt) > min_area]
956
+ return out
957
+
958
+
959
+ # ===========================================================================
960
+ # MAIN NPH PIPELINE
961
+ # ===========================================================================
962
+
963
+ def segment_nph(
964
+ image_path: str,
965
+ modality: str = "CT_HEAD",
966
+ structures: Optional[List[str]] = None,
967
+ output_path: Optional[str] = None,
968
+ pixel_spacing_mm: Optional[float] = None,
969
+ compute_biomarkers: bool = True,
970
+ ) -> SegmentationResult:
971
+ """
972
+ Main entry point for NPH neuroimaging segmentation and analysis.
973
+
974
+ Args:
975
+ image_path: Path to input image (DICOM, PNG, JPG)
976
+ modality: 'CT_HEAD', 'T1', 'T1_GD', 'T2', 'FLAIR'
977
+ structures: Optional list of structures to segment
978
+ output_path: Optional path to save comparison image
979
+ pixel_spacing_mm: Pixel spacing for real-world measurements
980
+ compute_biomarkers: Whether to compute Evans' index, etc.
981
+
982
+ Returns:
983
+ SegmentationResult with masks, overlay, contours, and metadata
984
+ including NPH biomarkers
985
+ """
986
+ mod = Modality[modality.upper()]
987
+
988
+ # Load image
989
+ is_dicom = image_path.lower().endswith((".dcm", ".dicom"))
990
+ dicom_meta = {}
991
+
992
+ if is_dicom:
993
+ hu, dicom_meta = load_dicom(image_path)
994
+ pixel_spacing_mm = pixel_spacing_mm or dicom_meta.get("pixel_spacing_mm", [1.0])[0]
995
+ center, width_hu = CT_WINDOWS["brain"]
996
+ gray = apply_ct_window(hu, center, width_hu)
997
+ img_rgb = cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB)
998
+ else:
999
+ img_rgb, gray, _ = preprocess_image(image_path)
1000
+
1001
+ roi_mask = create_roi_mask(cv2.GaussianBlur(gray, (5, 5), 0), threshold=15)
1002
+
1003
+ # Default structures
1004
+ if structures is None:
1005
+ if mod == Modality.FLAIR:
1006
+ structures = ["ventricles", "pvh", "parenchyma"]
1007
+ else:
1008
+ structures = ["ventricles", "parenchyma"]
1009
+
1010
+ masks: Dict[str, np.ndarray] = {}
1011
+
1012
+ # Always segment ventricles
1013
+ vent_mask = segment_ventricles(gray, mod, roi_mask)
1014
+ masks["ventricles"] = vent_mask
1015
+
1016
+ # Parenchyma (brain tissue excluding ventricles)
1017
+ if "parenchyma" in structures:
1018
+ parenchyma = cv2.bitwise_and(roi_mask, cv2.bitwise_not(vent_mask))
1019
+ masks["parenchyma"] = parenchyma
1020
+
1021
+ # PVH on FLAIR
1022
+ pvh_data = None
1023
+ if "pvh" in structures and mod == Modality.FLAIR:
1024
+ pvh_data = score_pvh(gray, vent_mask)
1025
+ masks["pvh"] = pvh_data["pvh_mask"]
1026
+
1027
+ # Skull (for Evans' index)
1028
+ skull_mask = None
1029
+ if is_dicom and mod == Modality.CT_HEAD:
1030
+ bone_gray = apply_ct_window(hu, *CT_WINDOWS["bone"])
1031
+ skull_mask = segment_skull(bone_gray, threshold=180)
1032
+ masks["skull"] = skull_mask
1033
+
1034
+ # Compute biomarkers
1035
+ biomarkers = {}
1036
+ if compute_biomarkers:
1037
+ ei_data = compute_evans_index(vent_mask, skull_mask, gray.shape[1], pixel_spacing_mm)
1038
+ biomarkers.update(ei_data)
1039
+
1040
+ th_data = compute_temporal_horn_width(vent_mask, pixel_spacing_mm)
1041
+ biomarkers.update(th_data)
1042
+
1043
+ tv_data = compute_third_ventricle_width(vent_mask, pixel_spacing_mm)
1044
+ biomarkers.update(tv_data)
1045
+
1046
+ if pvh_data:
1047
+ biomarkers["pvh_grade"] = pvh_data["pvh_grade"]
1048
+ biomarkers["pvh_ratio"] = pvh_data["pvh_ratio"]
1049
+
1050
+ # DESH assessment
1051
+ desh_data = assess_desh(vent_mask, gray, roi_mask, mod, pixel_spacing_mm)
1052
+ biomarkers["is_desh_positive"] = desh_data["is_desh_positive"]
1053
+ biomarkers["desh_total_score"] = desh_data["total_score"]
1054
+ biomarkers["desh_details"] = {
1055
+ k: v for k, v in desh_data.items()
1056
+ if k not in ("sylvian_mask", "convexity_mask")
1057
+ }
1058
+
1059
+ # Remove non-display masks
1060
+ display_masks = {k: v for k, v in masks.items() if k != "skull"}
1061
+
1062
+ # Visualization
1063
+ overlay = create_overlay(img_rgb, display_masks)
1064
+ annotated = add_annotations(overlay, display_masks, f"{modality} - NPH Analysis", biomarkers)
1065
+
1066
+ contours = _extract_contours(display_masks)
1067
+
1068
+ metadata = {
1069
+ "modality": modality,
1070
+ "structures_found": list(display_masks.keys()),
1071
+ "image_shape": img_rgb.shape,
1072
+ "pixel_spacing_mm": pixel_spacing_mm,
1073
+ "dicom_meta": dicom_meta,
1074
+ }
1075
+ metadata.update(biomarkers)
1076
+
1077
+ result = SegmentationResult(
1078
+ masks=display_masks, overlay=annotated, contours=contours, metadata=metadata
1079
+ )
1080
+
1081
+ if output_path:
1082
+ comparison = create_comparison(img_rgb, annotated, f"{modality} - NPH Analysis")
1083
+ Image.fromarray(comparison).save(output_path)
1084
+ print(f"Saved: {output_path}")
1085
+
1086
+ return result
1087
+
1088
+
1089
+ # Alias for backward compatibility
1090
+ segment_brain_image = segment_nph
1091
+
1092
+
1093
+ # ===========================================================================
1094
+ # CLI
1095
+ # ===========================================================================
1096
+
1097
+ if __name__ == "__main__":
1098
+ import sys
1099
+
1100
+ if len(sys.argv) < 2:
1101
+ print("Usage: python segment_neuroimaging.py <image_path> [modality] [output_path]")
1102
+ print(" modality: CT_HEAD, T1, T1_GD, T2, FLAIR (default: CT_HEAD)")
1103
+ sys.exit(1)
1104
+
1105
+ image_path = sys.argv[1]
1106
+ modality = sys.argv[2] if len(sys.argv) > 2 else "CT_HEAD"
1107
+ output_path = sys.argv[3] if len(sys.argv) > 3 else image_path.replace(".", "_nph_analysis.")
1108
+
1109
+ result = segment_nph(image_path, modality, output_path=output_path)
1110
+ print(f"\n--- NPH Analysis Results ---")
1111
+ print(f"Structures: {result.metadata['structures_found']}")
1112
+ ei = result.metadata.get("evans_index")
1113
+ if ei is not None:
1114
+ print(f"Evans' Index: {ei:.3f} ({'ABNORMAL (>0.3)' if ei > 0.3 else 'Normal'})")
1115
+ thw = result.metadata.get("temporal_horn_width_px")
1116
+ if thw is not None:
1117
+ print(f"Temporal Horn Width: {thw} px")
1118
+ pvh = result.metadata.get("pvh_grade")
1119
+ if pvh is not None:
1120
+ print(f"PVH Grade: {pvh}/3")
1121
+ desh = result.metadata.get("is_desh_positive")
1122
+ if desh is not None:
1123
+ print(f"DESH Pattern: {'POSITIVE' if desh else 'negative'}")