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