EyeQC / src /vessels.py
rdharini2001's picture
EyeQC
6fe482b
Raw
History Blame Contribute Delete
9.01 kB
"""
Rigorous retinal vessel analysis for gradability.
The visibility and integrity of the vascular tree is the single most reliable
proxy for whether a fundus image is gradable. We compute a multi-scale vessel
probability map and derive structural descriptors (density, mean vesselness,
skeletal length, fractal dimension, fragmentation) that degrade predictably
under blur, haze and shadow.
Two segmentation backends:
* classical (default, no weights): CLAHE + multi-scale Frangi vesselness on the
inverted green channel, restricted to the analysis disc, with hysteresis and
morphological cleanup. Validated to be blur-monotonic.
* deep (optional): register any callable seg(rgb_uint8) -> float32 HxW in [0,1]
via set_deep_segmenter(). Use this to plug in a trained vessel or artery/vein
U-Net on your host; the rest of the pipeline is identical.
"""
from __future__ import annotations
import numpy as np
import cv2
from skimage.filters import frangi
from skimage.morphology import skeletonize
from scipy import ndimage as ndi
_DEEP_SEGMENTER = None
_AV_SEGMENTER = None
def set_av_segmenter(fn):
"""Register a deep artery/vein/vessel segmenter:
fn(rgb_uint8) -> dict(vessel, artery, vein), each float32 HxW in [0,1]."""
global _AV_SEGMENTER
_AV_SEGMENTER = fn
def has_av_segmenter():
return _AV_SEGMENTER is not None
def _remove_small(binary, min_size):
"""Drop connected components smaller than min_size (deprecation-free)."""
lbl, n = ndi.label(binary)
if n == 0:
return binary
sizes = ndi.sum(np.ones_like(lbl), lbl, index=np.arange(1, n + 1))
keep = np.zeros(n + 1, bool)
keep[1:][sizes >= min_size] = True
return keep[lbl]
def tophat_vessel_contrast(rgb, mask):
"""Blur-monotonic vessel-contrast energy: black-top-hat on the green channel
isolates dark vessels; its energy inside the disc collapses under blur.
Robust backbone of the vessel gradability score."""
g = rgb[..., 1].astype(np.uint8)
g = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(g)
ker = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (13, 13))
bth = cv2.morphologyEx(g, cv2.MORPH_BLACKHAT, ker).astype(np.float32)
inside = bth[mask]
if inside.size == 0:
return 0.0
return float(np.sqrt(np.mean(inside ** 2)))
def set_deep_segmenter(fn):
"""Register a deep vessel segmenter: fn(rgb_uint8 HxWx3) -> prob map HxW in [0,1]."""
global _DEEP_SEGMENTER
_DEEP_SEGMENTER = fn
def has_deep_segmenter():
return _DEEP_SEGMENTER is not None
# ----------------------------------------------------------------- preprocessing
def _green_clahe(rgb, mask):
g = rgb[..., 1].astype(np.uint8)
clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8))
g = clahe.apply(g)
# illumination flattening inside the disc
bg = cv2.medianBlur(g, 55)
flat = cv2.normalize(g.astype(np.float32) - bg.astype(np.float32), None, 0, 1,
cv2.NORM_MINMAX)
flat[~mask] = 0
return flat
# ----------------------------------------------------------------- segmentation
def _classical_vesselness(rgb, mask):
flat = _green_clahe(rgb, mask)
# multi-scale Frangi: vessels are dark ridges on inverted green
inv = flat.max() - flat
scales = np.arange(1.0, 5.5, 0.75)
v = frangi(inv, sigmas=scales, black_ridges=False)
v = cv2.normalize(v, None, 0, 1, cv2.NORM_MINMAX)
v[~mask] = 0
return v.astype(np.float32)
def segment_vessels(rgb, mask):
"""Return a vessel probability map in [0,1] restricted to `mask`."""
if _AV_SEGMENTER is not None:
try:
av = _AV_SEGMENTER(rgb)
p = np.asarray(av["vessel"], np.float32)
p[~mask] = 0
return p, "deep-rrwnet"
except Exception:
pass
if _DEEP_SEGMENTER is not None:
try:
p = np.asarray(_DEEP_SEGMENTER(rgb)).astype(np.float32)
if p.max() > 1.0:
p = p / 255.0
p[~mask] = 0
return p, "deep"
except Exception:
pass # fall back to classical
return _classical_vesselness(rgb, mask), "classical"
def _caliber(binary):
"""Mean vessel caliber = area / skeleton length (px)."""
n = int(binary.sum())
if n < 20:
return 0.0
sk = skeletonize(binary)
L = int(sk.sum())
return float(n / L) if L > 0 else 0.0
def compute_avr(artery, vein, mask, thr=0.5):
"""Arteriolar-to-venular ratio from A/V probability maps."""
a = (artery > thr) & mask
v = (vein > thr) & mask
ca, cv = _caliber(a), _caliber(v)
avr = float(ca / cv) if cv > 0 else float("nan")
return dict(avr=avr, artery_caliber=ca, vein_caliber=cv,
artery_frac=float(a.sum()) / max(int(mask.sum()), 1),
vein_frac=float(v.sum()) / max(int(mask.sum()), 1))
def _binarize(vprob, mask):
"""Hysteresis threshold -> clean binary vessel mask."""
inside = vprob[mask]
if inside.size == 0:
return np.zeros_like(vprob, bool)
hi = np.quantile(inside, 0.93)
lo = np.quantile(inside, 0.80)
strong = vprob >= hi
weak = vprob >= lo
# keep weak pixels connected to strong (hysteresis)
lbl, n = ndi.label(weak)
keep = np.zeros(n + 1, bool)
keep[np.unique(lbl[strong])] = True
keep[0] = False
b = keep[lbl] & mask
b = ndi.binary_closing(b, structure=np.ones((3, 3)))
b = _remove_small(b, 40)
return b
# ----------------------------------------------------------------- descriptors
def _fractal_dimension(binary):
"""Box-counting fractal dimension of the vessel skeleton."""
Z = binary > 0
if Z.sum() < 20:
return 0.0
p = min(Z.shape)
n = 2 ** int(np.floor(np.log2(p)))
sizes = 2 ** np.arange(int(np.log2(n)), 1, -1)
counts = []
for size in sizes:
cnt = 0
for i in range(0, Z.shape[0], size):
for j in range(0, Z.shape[1], size):
if Z[i:i+size, j:j+size].any():
cnt += 1
counts.append(cnt)
counts = np.array(counts, float)
sizes = np.array(sizes, float)
ok = counts > 0
if ok.sum() < 2:
return 0.0
coeffs = np.polyfit(np.log(1.0 / sizes[ok]), np.log(counts[ok]), 1)
return float(coeffs[0])
def analyze_vessels(rgb, fov):
"""Full vessel analysis. Returns a descriptor dict + maps."""
from .qc_metrics import inner_disc_mask
mask = inner_disc_mask(rgb.shape, fov, frac=0.92)
vprob, backend = segment_vessels(rgb, mask)
vbin = _binarize(vprob, mask)
area = max(int(mask.sum()), 1)
density = float(vbin.sum()) / area # vessel area fraction
mean_vesselness = float(vprob[mask].mean()) # continuous strength
skel = skeletonize(vbin)
skel_len = float(skel.sum()) / area # normalised length
fractal = _fractal_dimension(skel) # branching complexity
lbl, ncomp = ndi.label(vbin)
frag = ncomp / (skel.sum() + 1e-6) * 100 # comps per 100 skel px
contrast = tophat_vessel_contrast(rgb, mask) # monotonic backbone
out = dict(
backend=backend, density=density, mean_vesselness=mean_vesselness,
skeleton_length=skel_len, fractal_dimension=fractal, fragmentation=frag,
n_components=int(ncomp), contrast=contrast,
prob_map=vprob, binary=vbin, skeleton=skel, mask=mask,
)
# artery/vein maps + AVR biomarker when a deep A/V segmenter is registered
if _AV_SEGMENTER is not None:
try:
av = _AV_SEGMENTER(rgb)
out["artery"] = np.asarray(av["artery"], np.float32)
out["vein"] = np.asarray(av["vein"], np.float32)
out["avr"] = compute_avr(out["artery"], out["vein"], mask)
except Exception:
pass
return out
def vessel_gradability_score(desc):
"""0-1 vessel gradability. The blur-monotonic top-hat contrast is the primary
driver; structural descriptors (rigorous with a deep segmenter) refine it."""
def curve(x, x_fail, x_pass):
if x_pass == x_fail:
return 0.5
return float(np.clip((x - x_fail) / (x_pass - x_fail), 0, 1))
s_contrast = curve(desc["contrast"], 6.0, 20.0) # PRIMARY, monotonic
s_fractal = curve(desc["fractal_dimension"], 1.05, 1.55) # branching richness
s_frag = 1.0 - curve(desc["fragmentation"], 3.0, 14.0) # fragmentation
if desc["backend"] == "deep":
# trust structural evidence fully with a real segmentation
parts = np.array([s_contrast, s_fractal, s_frag])
score = 0.4 * parts.mean() + 0.6 * parts.min()
else:
# classical structural terms are approximate: contrast-dominated
score = 0.75 * s_contrast + 0.15 * s_fractal + 0.10 * s_frag
return float(np.clip(score, 0, 1)), dict(
contrast=s_contrast, fractal=s_fractal, fragmentation=s_frag)