Spaces:
Paused
Paused
File size: 18,930 Bytes
5279a70 | 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 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 | """Vascular leakage quantification from FITC-dextran / IB4 dual-label images.
Designed for confocal images (orthogonal projections or single planes) of
cochlear / stria vascularis capillary networks labeled with:
* FITC-dextran (green): intravascular tracer that extravasates into the
surrounding tissue when the vascular barrier is compromised.
* Isolectin B4 - 594 (red): endothelial / vessel wall marker.
* Optional DAPI (blue): ignored.
Pipeline
--------
1. Channel extraction. Merged RGB images and 2-3 plane stacks supported.
Both channels are scaled by one common divisor so their intensities stay
mutually comparable. Burned-in annotations (scale bars, text) are
detected as co-saturated pixels and excluded.
2. Tissue mask: the specimen footprint. Dark interstitium enclosed by
tissue is kept (leak-free tissue must count in the denominator); wide
camera-background regions connected to the frame border - the black
corners of rotated acquisitions, empty space beside the specimen - are
excluded.
3. Vessel segmentation. IB4 staining quality varies strongly between
specimens, so the vascular compartment is the union of two detectors:
a. IB4 branch - background-flattened hysteresis threshold of the red
channel.
b. FITC-tube branch - multi-scale Sato vesselness on the green
channel, gated by intensity. Perfused capillaries are bright
*tubes* of FITC; extravasated dye is spatially diffuse and has no
tubular geometry, so this branch recovers vessels that IB4 missed
without absorbing genuine leak.
A small dilation assigns the vessel wall to the intravascular
compartment, and a guard-band annulus just beyond it is excluded from
both compartments: it carries the point-spread halo of the bright
lumen, which would otherwise read as leak even in leak-free controls.
4. Metrics, defined so that each one moves with leakage alone:
* leakage_ratio - background-corrected mean extravascular FITC over
the background-corrected luminal FITC concentration. The luminal
reference is a high percentile of FITC inside the vessel core, so
it is estimated from vessel pixels only and cannot drift with the
extravascular distribution.
* leakage_area_fraction - fraction of extravascular tissue carrying
tracer at or above LEAK_FRACTION_OF_LUMEN of luminal concentration.
The criterion is anchored to the lumen rather than to a global
Otsu split, which would jump discontinuously when the histogram
changes mode.
The FITC background floor is estimated from tracer-free pixels: camera
background outside the specimen when available, otherwise the darkest
tissue percentile, with a warning that whole-field diffuse leak cannot
be separated from the offset in that case.
All steps are deterministic: the same image always yields the same numbers.
Every tunable scales with image resolution (baseline 1024 px, supported up
to 2048 px; larger inputs are flagged in the result warnings).
"""
from dataclasses import dataclass, field
import numpy as np
from scipy import ndimage as ndi
from skimage import filters, morphology
# Extravascular tracer at or above this fraction of the luminal
# concentration counts as leak-positive area.
LEAK_FRACTION_OF_LUMEN = 0.20
# Percentile of FITC within the vessel core taken as the luminal tracer
# concentration. High enough to represent filled lumen, robust to the
# unperfused vessel pixels and wall pixels also present in the core mask.
LUMEN_PERCENTILE = 90.0
MAX_SUPPORTED_SIDE = 2048
# ---------------------------------------------------------------------------
# Channel handling
# ---------------------------------------------------------------------------
def split_channels(img):
"""Return (fitc, ib4, annotation) as float32 / bool 2-D arrays.
Accepts:
* (H, W, 3) or (H, W, 4) RGB(A) merges -> green = FITC, red = IB4
* (2|3, H, W) plane stacks -> plane 0 = FITC, plane 1 = IB4
* (H, W, 2) channel-last stacks -> same convention
Both channels are divided by one common scale factor, so absolute
intensities remain comparable between the two channels and between
images of the same bit depth.
``annotation`` marks burned-in white overlays (scale bars, text):
compact clusters of pixels near saturation in every channel at once,
which does not occur for genuine two-fluorophore signal.
"""
a = np.asarray(img)
if a.ndim == 2:
raise ValueError(
"Single-channel image: need both a FITC and an IB4 channel.")
if a.ndim != 3:
raise ValueError(f"Unsupported image shape {a.shape}.")
divisor = _divisor(a)
if a.shape[-1] in (3, 4) and a.shape[0] not in (2, 3):
fitc, ib4, third = a[..., 1], a[..., 0], a[..., 2]
elif a.shape[0] in (2, 3):
fitc, ib4 = a[0], a[1]
third = a[2] if a.shape[0] == 3 else None
elif a.shape[-1] == 2:
fitc, ib4 = a[..., 0], a[..., 1]
third = None
else:
raise ValueError(f"Cannot infer channel layout for shape {a.shape}.")
fitc = _scale_to_unit(fitc, divisor)
ib4 = _scale_to_unit(ib4, divisor)
# A grayscale image replicated across channels carries no FITC/IB4
# separation; every downstream comparison would be degenerate.
if np.allclose(fitc, ib4, atol=1e-4):
raise ValueError(
"The FITC and IB4 channels are identical: this looks like a "
"grayscale image rather than a dual-label merge.")
annotation = (fitc > 0.92) & (ib4 > 0.92)
if third is not None:
annotation &= _scale_to_unit(third, divisor) > 0.92
annotation = _compact_only(annotation, fitc.shape)
return fitc, ib4, annotation
def _divisor(a):
"""One scale factor for the whole image, from dtype and value range."""
mx = float(a.max()) if a.size else 0.0
if np.issubdtype(a.dtype, np.floating):
if mx <= 1.0:
return 1.0
return 255.0 if mx <= 255 else (4095.0 if mx <= 4095 else 65535.0)
if a.dtype == np.uint8 or a.dtype == np.int8:
return 255.0
if mx <= 255:
return 255.0
return 4095.0 if mx <= 4095 else 65535.0
def _scale_to_unit(ch, divisor):
ch = np.asarray(ch, dtype=np.float32) / np.float32(divisor)
return np.clip(ch, 0.0, 1.0)
def _compact_only(mask, shape):
"""Keep only locally dense parts of a mask, then pad them slightly."""
if not mask.any():
return mask
s = _scale(shape)
dens = ndi.uniform_filter(mask.astype(np.float32),
size=max(3, int(round(5 * s))))
mask = mask & (dens > 0.6)
if not mask.any():
return mask
return morphology.binary_dilation(
mask, morphology.disk(max(2, int(round(2 * s)))))
def _scale(img_shape):
"""Resolution scale factor relative to the 1024 px design baseline."""
return float(np.clip(max(img_shape) / 1024.0, 0.5, 2.0))
# ---------------------------------------------------------------------------
# Masks
# ---------------------------------------------------------------------------
def tissue_mask(fitc, ib4, annotation=None, min_signal=0.02):
"""Footprint of the imaged specimen.
Signal-bearing regions are unified with a closing that bridges narrow
dark inlets, then filled, so interstitium enclosed by tissue counts as
tissue. Wide camera-background regions reachable from the frame border
are then removed, which excludes the black wedges of rotated
acquisitions and empty space beside the specimen.
"""
s = _scale(fitc.shape)
combined = np.maximum(fitc, ib4)
signal = filters.gaussian(combined, sigma=4 * s) > min_signal
signal = morphology.remove_small_objects(signal, min_size=int(1024 * s * s))
if not signal.any(): # uniformly dim image: use full frame
mask = np.ones_like(signal, dtype=bool)
else:
closed = morphology.binary_closing(
signal, morphology.disk(max(4, int(round(10 * s)))))
mask = ndi.binary_fill_holes(closed | signal)
mask &= ~_wide_border_background(signal, s)
if annotation is not None:
mask = mask & ~annotation
return mask
def _wide_border_background(signal, s):
"""Signal-free regions reachable from the frame border and wide enough
to be genuine empty background rather than a dark inlet in tissue."""
bg = ~signal
labels, n = ndi.label(bg)
if n == 0:
return np.zeros_like(signal)
border_ids = set()
for edge in (labels[0, :], labels[-1, :], labels[:, 0], labels[:, -1]):
border_ids.update(np.unique(edge).tolist())
border_ids.discard(0)
if not border_ids:
return np.zeros_like(signal)
border_bg = np.isin(labels, list(border_ids))
# "Wide" means the region survives an erosion of the expected inlet
# width; only components retaining a core are treated as background.
core = morphology.binary_erosion(
border_bg, morphology.disk(max(4, int(round(8 * s)))))
if not core.any():
return np.zeros_like(signal)
keep_ids = np.unique(labels[core])
keep_ids = keep_ids[keep_ids != 0]
wide = np.isin(labels, keep_ids)
return ndi.binary_dilation(wide, iterations=2)
def _segment_ib4(ib4, tissue, s):
"""Intensity-based vessel detection on the IB4 (red) channel."""
smooth = filters.gaussian(ib4, sigma=1.5 * s)
background = filters.gaussian(ib4, sigma=50 * s)
flat = np.clip(smooth - background, 0, None)
inside = flat[tissue]
if inside.size == 0 or float(inside.max()) <= 0:
return np.zeros_like(tissue, dtype=bool)
otsu = filters.threshold_otsu(inside)
mask = filters.apply_hysteresis_threshold(flat, 0.5 * otsu, otsu)
return mask & tissue
def _segment_fitc_tubes(fitc, tissue, s):
"""Geometry-based detection of FITC-perfused capillaries.
Perfused vessels are bright tubular structures in the FITC channel;
extravasated dye is diffuse. Multi-scale Sato vesselness responds to
tubes across the capillary calibre range and is additionally gated by
intensity so faint texture in leak clouds is not picked up.
"""
smooth = filters.gaussian(fitc, sigma=1.0 * s)
background = filters.gaussian(fitc, sigma=50 * s)
flat = np.clip(smooth - background, 0, None)
inside = flat[tissue]
if inside.size == 0 or float(inside.max()) <= 0:
return np.zeros_like(tissue, dtype=bool)
sigmas = [c * s for c in (2, 4, 6, 9, 13)]
vess = filters.sato(flat, sigmas=sigmas, black_ridges=False, mode="reflect")
v_in = vess[tissue]
if float(v_in.max()) <= 0:
return np.zeros_like(tissue, dtype=bool)
v_thr = filters.threshold_otsu(v_in)
i_thr = 0.5 * filters.threshold_otsu(inside)
return (vess > 0.5 * v_thr) & (flat > i_thr) & tissue
def segment_vessels(ib4, fitc, tissue, wall_dilation_px=None):
"""Segment the vascular compartment using both markers.
Returns (vessel_core, vessel_dilated, guard).
"""
s = _scale(ib4.shape)
if wall_dilation_px is None:
wall_dilation_px = max(2, int(round(3 * s)))
guard_px = max(3, int(round(4 * s)))
mask = _segment_ib4(ib4, tissue, s) | _segment_fitc_tubes(fitc, tissue, s)
mask = morphology.binary_closing(mask, morphology.disk(max(2, int(round(2 * s)))))
mask = morphology.remove_small_holes(mask, area_threshold=int(400 * s * s))
mask = _fill_bright_holes(mask, fitc, tissue)
mask = morphology.remove_small_objects(mask, min_size=int(96 * s * s))
dilated = morphology.binary_dilation(mask, morphology.disk(wall_dilation_px))
guard = morphology.binary_dilation(dilated, morphology.disk(guard_px)) & ~dilated
return mask & tissue, dilated & tissue, guard & tissue
def _fill_bright_holes(mask, fitc, tissue):
"""Fill enclosed holes in the vessel mask whose FITC content is bright.
Bright enclosed holes are unstained blood cells or uneven lumen texture
surrounded by tracer; assigning them to the extravascular compartment
would dilute it with intraluminal pixels. Dark enclosed holes are
avascular tissue inside capillary loops and are left extravascular.
"""
holes = ndi.binary_fill_holes(mask) & ~mask
if not holes.any():
return mask
t_fitc = fitc[tissue]
if t_fitc.size == 0 or float(t_fitc.max()) <= 0:
return mask
thr = filters.threshold_otsu(t_fitc)
labels, n = ndi.label(holes)
if n == 0:
return mask
means = np.asarray(ndi.mean(fitc, labels=labels, index=np.arange(1, n + 1)))
bright = np.flatnonzero(means >= thr) + 1
return mask | np.isin(labels, bright) if bright.size else mask
# ---------------------------------------------------------------------------
# Background floor
# ---------------------------------------------------------------------------
def estimate_background(fitc, tissue):
"""Estimate the FITC background floor (detector offset) from tracer-free
pixels only.
The floor is taken from camera background outside the specimen: those
pixels cannot contain tracer, so the estimate is independent of how much
dye has leaked. When the specimen fills the frame no tracer-free region
exists and the floor is left at zero, with a note in the warnings.
Estimating the floor from a low percentile of tissue instead - the
obvious alternative - is not safe: diffuse extravasation raises the
darkest tissue in lockstep with the leak, so the correction would
subtract exactly the signal being measured and severe whole-field
leakage would read as none.
Returns (background, warnings).
"""
outside = ~tissue
if int(outside.sum()) >= max(256, int(0.005 * fitc.size)):
return float(np.median(fitc[outside])), []
return 0.0, [
"The specimen fills the frame, so no tracer-free region is available "
"to estimate the FITC background floor; intensities are not "
"background-corrected and may include a detector or out-of-focus "
"pedestal."]
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
@dataclass
class LeakageResult:
leakage_ratio: float # corrected extravascular mean / luminal concentration
leakage_area_fraction: float # extravascular area at >= 20% of luminal concentration
vessel_area_fraction: float # vessel core px / tissue px
perfused_vessel_area_fraction: float
lumen_fitc_concentration: float # corrected luminal reference (0-1)
mean_fitc_extravascular: float # raw mean FITC in extravascular tissue
fitc_background: float # estimated FITC background floor (0-1)
leak_threshold: float # raw FITC level counted as leak-positive
tissue_px: int = 0
vessel_px: int = 0
perfused_vessel_px: int = 0
extravascular_px: int = 0
warnings: list = field(default_factory=list)
def quantify(fitc, ib4, tissue, vessel_core, vessel_dilated, guard=None,
extra_warnings=None):
"""Compute leakage metrics from the channel images and masks."""
warnings = list(extra_warnings or [])
if guard is None:
guard = np.zeros_like(tissue, dtype=bool)
extravascular = tissue & ~vessel_dilated & ~guard
intravascular = vessel_dilated & tissue
n_in = int(intravascular.sum())
n_out = int(extravascular.sum())
n_tissue = int(tissue.sum())
n_core = int(vessel_core.sum())
background, bg_warn = estimate_background(fitc, tissue)
warnings.extend(bg_warn)
if n_in == 0 or n_core == 0:
warnings.append("No vessels detected: leakage metrics are undefined.")
return LeakageResult(float("nan"), float("nan"), 0.0, 0.0,
float("nan"), float("nan"), background,
float("nan"), n_tissue, 0, 0, n_out, warnings)
# Luminal tracer concentration: a high percentile of FITC inside the
# vessel core. Estimated from vessel pixels only, so it cannot drift
# with the extravascular distribution being measured against it.
lumen_raw = float(np.percentile(fitc[vessel_core], LUMEN_PERCENTILE))
lumen = max(lumen_raw - background, 0.0)
# Perfused lumen, for reporting: vessel pixels carrying tracer at or
# above the same lumen-anchored criterion used for leak-positive area.
leak_threshold = background + LEAK_FRACTION_OF_LUMEN * lumen
perfused = intravascular & (fitc >= leak_threshold)
n_perf = int(perfused.sum())
if lumen <= 0.02:
warnings.append(
"Luminal FITC is barely above the background floor; the leakage "
"metrics are unreliable for this image.")
if n_out < max(256, n_tissue // 100):
warnings.append(
"Almost no extravascular tissue outside the vascular "
"compartment; leakage metrics are not meaningful.")
mean_out_raw = float(fitc[extravascular].mean()) if n_out else float("nan")
if n_out:
corr_out = float(np.clip(fitc[extravascular] - background, 0, None).mean())
leak_px = int((fitc[extravascular] >= leak_threshold).sum())
area_frac = leak_px / n_out
else:
corr_out, area_frac = float("nan"), float("nan")
ratio = corr_out / lumen if lumen > 0 else float("nan")
return LeakageResult(
leakage_ratio=ratio,
leakage_area_fraction=area_frac,
vessel_area_fraction=n_core / max(n_tissue, 1),
perfused_vessel_area_fraction=n_perf / max(n_tissue, 1),
lumen_fitc_concentration=lumen,
mean_fitc_extravascular=mean_out_raw,
fitc_background=background,
leak_threshold=leak_threshold,
tissue_px=n_tissue,
vessel_px=n_core,
perfused_vessel_px=n_perf,
extravascular_px=n_out,
warnings=warnings,
)
def analyze(img, wall_dilation_px=None):
"""Full pipeline on one image array. Returns (result, intermediates)."""
fitc, ib4, annotation = split_channels(img)
extra = []
if max(fitc.shape) > MAX_SUPPORTED_SIDE:
extra.append(
f"Image is larger than {MAX_SUPPORTED_SIDE} px: internal length "
"scales are capped, so vessels may be under-detected. Downscale "
"the image before analysis.")
tissue = tissue_mask(fitc, ib4, annotation)
core, dilated, guard = segment_vessels(ib4, fitc, tissue, wall_dilation_px)
result = quantify(fitc, ib4, tissue, core, dilated, guard, extra)
inter = {
"fitc": fitc, "ib4": ib4, "tissue": tissue,
"vessel_core": core, "vessel_dilated": dilated, "guard": guard,
"annotation": annotation,
}
return result, inter
|