vascular-2 / processing.py
stevafernandes's picture
Upload 7 files
5279a70 verified
Raw History Blame Contribute Delete
18.9 kB
"""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