Spaces:
Running
Running
Upload 317 files
Browse files- README.md +12 -6
- __pycache__/app.cpython-313.pyc +0 -0
- __pycache__/processing.cpython-313.pyc +0 -0
- app.py +11 -7
- processing.py +127 -2
README.md
CHANGED
|
@@ -16,15 +16,21 @@ A variant of the neuron tracer that applies the **MedCLIPSeg** approach
|
|
| 16 |
(Koleilat et al., *Probabilistic Vision–Language Adaptation for Data-Efficient
|
| 17 |
and Generalizable Medical Image Segmentation*, CVPR 2026): a **text prompt**
|
| 18 |
describes the target ("nerve fibers …") and a frozen **CLIP** backbone produces a
|
| 19 |
-
**fiber-probability map**
|
|
|
|
| 20 |
|
| 21 |
## What the app does
|
| 22 |
- **Neurofilament (MIP):** the maximum-intensity projection of the input channel.
|
| 23 |
-
- **Tracked skeleton (white on black):**
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
## Few-shot training (included)
|
| 30 |
A runnable, few-shot version of the MedCLIPSeg approach — **frozen CLIP encoders +
|
|
|
|
| 16 |
(Koleilat et al., *Probabilistic Vision–Language Adaptation for Data-Efficient
|
| 17 |
and Generalizable Medical Image Segmentation*, CVPR 2026): a **text prompt**
|
| 18 |
describes the target ("nerve fibers …") and a frozen **CLIP** backbone produces a
|
| 19 |
+
**fiber-probability map** that acts as a *semantic region prior* — telling the
|
| 20 |
+
tracer **where** the nerve fibers are.
|
| 21 |
|
| 22 |
## What the app does
|
| 23 |
- **Neurofilament (MIP):** the maximum-intensity projection of the input channel.
|
| 24 |
+
- **Tracked skeleton (white on black):** MedCLIP-gated tubular tracing. The
|
| 25 |
+
MedCLIP probability map (WHERE) seeds a **Sato tubeness** ridge filter run on
|
| 26 |
+
the raw neurofilament signal (the thin fiber GEOMETRY); seeds grow along the
|
| 27 |
+
raw ridge across the whole field, so the long radial fibers below the band are
|
| 28 |
+
recovered, and a width gate removes any residual medial-axis mesh. This traces
|
| 29 |
+
clean thin fibers instead of skeletonising the coarse probability blob (which
|
| 30 |
+
produced a "cracked-mud" mesh).
|
| 31 |
+
- **Total trace length (µm):** the spacing-aware total length of that skeleton,
|
| 32 |
+
reported above the images. The text prompt and the MedCLIP **semantic gate**
|
| 33 |
+
(region-prior threshold) are editable.
|
| 34 |
|
| 35 |
## Few-shot training (included)
|
| 36 |
A runnable, few-shot version of the MedCLIPSeg approach — **frozen CLIP encoders +
|
__pycache__/app.cpython-313.pyc
CHANGED
|
Binary files a/__pycache__/app.cpython-313.pyc and b/__pycache__/app.cpython-313.pyc differ
|
|
|
__pycache__/processing.cpython-313.pyc
CHANGED
|
Binary files a/__pycache__/processing.cpython-313.pyc and b/__pycache__/processing.cpython-313.pyc differ
|
|
|
app.py
CHANGED
|
@@ -26,10 +26,11 @@ def analyze(file_obj, fg_prompt, threshold):
|
|
| 26 |
if fg_prompt and fg_prompt.strip():
|
| 27 |
MC.FG_PROMPTS = [p.strip() for p in fg_prompt.split("|") if p.strip()]
|
| 28 |
|
| 29 |
-
# --- MedCLIP text-prompted
|
| 30 |
prob, _ = MC.segment_best(nf_mip)
|
| 31 |
-
trace = P.
|
| 32 |
-
|
|
|
|
| 33 |
m = P.compute_metrics(trace, "MedCLIP", min_fiber_um=5.0)
|
| 34 |
skel_img = P.skeleton_image(trace.skeleton, dilate=1) # white on black
|
| 35 |
|
|
@@ -37,8 +38,10 @@ def analyze(file_obj, fg_prompt, threshold):
|
|
| 37 |
else "zero-shot CLIP prior")
|
| 38 |
status = (f"**MedCLIP total trace length: "
|
| 39 |
f"{m.total_length_um:,.1f} µm.**\n\n"
|
| 40 |
-
f"
|
| 41 |
-
f"(
|
|
|
|
|
|
|
| 42 |
f"Prompt(s): *{', '.join(MC.FG_PROMPTS)}*.")
|
| 43 |
return nf_mip, skel_img, status
|
| 44 |
except Exception as e: # noqa: BLE001
|
|
@@ -101,8 +104,9 @@ with gr.Blocks(title="Neuron Quantification using AI — MedCLIPSeg",
|
|
| 101 |
label="Text prompt(s) for the target (separate with | )",
|
| 102 |
value="a fluorescence microscopy image of nerve fibers | "
|
| 103 |
"neurofilament nerve fibers and axons")
|
| 104 |
-
thresh = gr.Slider(0.
|
| 105 |
-
label="
|
|
|
|
| 106 |
btn = gr.Button("Analyze", variant="primary")
|
| 107 |
with gr.Column(scale=2):
|
| 108 |
status = gr.Markdown()
|
|
|
|
| 26 |
if fg_prompt and fg_prompt.strip():
|
| 27 |
MC.FG_PROMPTS = [p.strip() for p in fg_prompt.split("|") if p.strip()]
|
| 28 |
|
| 29 |
+
# --- MedCLIP text-prompted region prior -> raw-signal fiber geometry ---
|
| 30 |
prob, _ = MC.segment_best(nf_mip)
|
| 31 |
+
trace = P.trace_fibers_medclip_gated(nf_mip, prob, img.voxel,
|
| 32 |
+
gate_threshold=float(threshold),
|
| 33 |
+
prune_um=3.0)
|
| 34 |
m = P.compute_metrics(trace, "MedCLIP", min_fiber_um=5.0)
|
| 35 |
skel_img = P.skeleton_image(trace.skeleton, dilate=1) # white on black
|
| 36 |
|
|
|
|
| 38 |
else "zero-shot CLIP prior")
|
| 39 |
status = (f"**MedCLIP total trace length: "
|
| 40 |
f"{m.total_length_um:,.1f} µm.**\n\n"
|
| 41 |
+
f"Fiber geometry traced from the raw neurofilament signal "
|
| 42 |
+
f"(Sato tubeness), gated by the MedCLIP region prior "
|
| 43 |
+
f"({model}, text-prompted) at semantic gate "
|
| 44 |
+
f"{float(threshold):.2f}. "
|
| 45 |
f"Prompt(s): *{', '.join(MC.FG_PROMPTS)}*.")
|
| 46 |
return nf_mip, skel_img, status
|
| 47 |
except Exception as e: # noqa: BLE001
|
|
|
|
| 104 |
label="Text prompt(s) for the target (separate with | )",
|
| 105 |
value="a fluorescence microscopy image of nerve fibers | "
|
| 106 |
"neurofilament nerve fibers and axons")
|
| 107 |
+
thresh = gr.Slider(0.2, 0.8, value=0.4, step=0.05,
|
| 108 |
+
label="MedCLIP semantic gate (region prior) — "
|
| 109 |
+
"lower = include more of the band")
|
| 110 |
btn = gr.Button("Analyze", variant="primary")
|
| 111 |
with gr.Column(scale=2):
|
| 112 |
status = gr.Markdown()
|
processing.py
CHANGED
|
@@ -21,8 +21,9 @@ from typing import Optional
|
|
| 21 |
|
| 22 |
import numpy as np
|
| 23 |
from scipy import ndimage as ndi
|
| 24 |
-
from skimage.filters import gaussian, threshold_otsu
|
| 25 |
-
from skimage.morphology import remove_small_objects,
|
|
|
|
| 26 |
from skan import Skeleton, summarize
|
| 27 |
|
| 28 |
|
|
@@ -598,6 +599,130 @@ def trace_from_probability(prob: np.ndarray, voxel: tuple,
|
|
| 598 |
return TraceResult(mask[None], skel, dist[None], voxel)
|
| 599 |
|
| 600 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
# --------------------------------------------------------------------------- #
|
| 602 |
# Region definition (IHC vs OHC) from Myo7a
|
| 603 |
# --------------------------------------------------------------------------- #
|
|
|
|
| 21 |
|
| 22 |
import numpy as np
|
| 23 |
from scipy import ndimage as ndi
|
| 24 |
+
from skimage.filters import gaussian, threshold_otsu, sato
|
| 25 |
+
from skimage.morphology import (remove_small_objects, remove_small_holes,
|
| 26 |
+
skeletonize)
|
| 27 |
from skan import Skeleton, summarize
|
| 28 |
|
| 29 |
|
|
|
|
| 599 |
return TraceResult(mask[None], skel, dist[None], voxel)
|
| 600 |
|
| 601 |
|
| 602 |
+
def _safe_otsu(values: np.ndarray, fallback: float) -> float:
|
| 603 |
+
"""threshold_otsu that never raises on constant / tiny inputs."""
|
| 604 |
+
v = np.asarray(values).ravel()
|
| 605 |
+
v = v[np.isfinite(v)]
|
| 606 |
+
if v.size < 8 or np.unique(v).size < 2:
|
| 607 |
+
return float(fallback)
|
| 608 |
+
try:
|
| 609 |
+
return float(threshold_otsu(v))
|
| 610 |
+
except Exception:
|
| 611 |
+
return float(fallback)
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def trace_fibers_medclip_gated(nf_mip: np.ndarray, prob: np.ndarray, voxel: tuple,
|
| 615 |
+
gate_threshold: float = 0.4,
|
| 616 |
+
fiber_radii_um: tuple = (0.2, 0.35, 0.6),
|
| 617 |
+
gate_dilate_um: float = 4.0,
|
| 618 |
+
max_fiber_half_width_um: float = 1.5,
|
| 619 |
+
bright_floor: float = 0.5,
|
| 620 |
+
min_object_px: int = 32,
|
| 621 |
+
prune_um: float = 3.0) -> TraceResult:
|
| 622 |
+
"""MedCLIP-gated tubular fiber tracer.
|
| 623 |
+
|
| 624 |
+
MedCLIP ``prob`` is used ONLY as a semantic region prior (WHERE fibers are).
|
| 625 |
+
The thin fiber GEOMETRY comes from a multiscale Sato tubeness response on the
|
| 626 |
+
RAW neurofilament MIP, so ``skeletonize`` acts on thin ridges (clean lines),
|
| 627 |
+
never on the coarse probability slab (which would yield a medial-axis
|
| 628 |
+
honeycomb). Seeds are placed where strong tubeness meets the MedCLIP region;
|
| 629 |
+
those seeds are then grown along the raw ridge over the WHOLE image, so the
|
| 630 |
+
long radial (vertical) spiral-ganglion fibers descending below the band -
|
| 631 |
+
where ``prob`` is ~0 - are recovered. A final EDT half-width gate deletes any
|
| 632 |
+
residual wide medial-axis ridge. Returns a Z=1 ``TraceResult`` so
|
| 633 |
+
``compute_metrics`` / ``skeleton_image`` / the length path are unchanged.
|
| 634 |
+
|
| 635 |
+
Parameters
|
| 636 |
+
----------
|
| 637 |
+
nf_mip : 2D uint8/float RAW neurofilament MIP (``channel_preview`` output).
|
| 638 |
+
prob : 2D MedCLIP fiber-probability in [0, 1] at MIP resolution.
|
| 639 |
+
voxel : (dz, dy, dx) in microns.
|
| 640 |
+
gate_threshold : MedCLIP prob level defining the semantic region (UI slider).
|
| 641 |
+
"""
|
| 642 |
+
dz, dy, dx = voxel
|
| 643 |
+
px = float(np.mean([dy, dx])) # microns / pixel
|
| 644 |
+
raw = np.asarray(nf_mip, np.float32)
|
| 645 |
+
prob = np.asarray(prob, np.float32)
|
| 646 |
+
if prob.shape != raw.shape: # robust to any mismatch
|
| 647 |
+
from skimage.transform import resize
|
| 648 |
+
prob = resize(prob, raw.shape, order=1, preserve_range=True).astype(np.float32)
|
| 649 |
+
|
| 650 |
+
def _empty():
|
| 651 |
+
z = np.zeros((1,) + raw.shape, bool)
|
| 652 |
+
return TraceResult(z, z.copy(), np.zeros(z.shape, np.float32), voxel)
|
| 653 |
+
|
| 654 |
+
# 1. Raw intensity, robustly normalised (reuse existing helper).
|
| 655 |
+
I = _norm(raw)
|
| 656 |
+
|
| 657 |
+
# 2. Multiscale tubeness on the RAW signal -> thin, orientation-invariant
|
| 658 |
+
# ridge response (present for vertical radials as much as band fibers).
|
| 659 |
+
sigmas = sorted({max(1.0, round(float(r) / max(dx, 1e-6), 2))
|
| 660 |
+
for r in fiber_radii_um})
|
| 661 |
+
T = sato(I, sigmas=sigmas, black_ridges=False).astype(np.float32)
|
| 662 |
+
T = np.nan_to_num(T, nan=0.0, posinf=0.0, neginf=0.0)
|
| 663 |
+
thi = np.percentile(T, 99.5)
|
| 664 |
+
T = np.clip(T / (thi + 1e-6), 0.0, 1.0)
|
| 665 |
+
|
| 666 |
+
# 3. MedCLIP semantic region (WHERE) -> generous, dilated gate.
|
| 667 |
+
region = prob >= float(gate_threshold)
|
| 668 |
+
if not region.any():
|
| 669 |
+
region = prob >= np.percentile(prob, 90.0) # never empty
|
| 670 |
+
if region.any():
|
| 671 |
+
region = remove_small_objects(region, int(min_object_px))
|
| 672 |
+
region = ndi.binary_closing(region, structure=np.ones((3, 3), bool))
|
| 673 |
+
if not region.any():
|
| 674 |
+
return _empty()
|
| 675 |
+
iters = max(1, int(round(float(gate_dilate_um) / px)))
|
| 676 |
+
gate = ndi.binary_dilation(region, iterations=iters)
|
| 677 |
+
|
| 678 |
+
# 4. Weak brightness floor (rejects pure-noise ridges; keeps dim radials).
|
| 679 |
+
ipos = I[I > 0]
|
| 680 |
+
bright = I >= float(bright_floor) * _safe_otsu(ipos, 0.15)
|
| 681 |
+
|
| 682 |
+
# 5. Seed = strong tubeness INSIDE the semantic gate.
|
| 683 |
+
thr_hi = _safe_otsu(T[gate & (T > 0)], 0.3)
|
| 684 |
+
thr_hi = float(np.clip(thr_hi, 0.02, 0.99))
|
| 685 |
+
thr_lo = 0.5 * thr_hi
|
| 686 |
+
core = (T >= thr_hi) & gate & bright
|
| 687 |
+
|
| 688 |
+
# 6. Growth domain = thin raw ridges over the WHOLE image (radial recovery).
|
| 689 |
+
permissive = (T >= thr_lo) & bright
|
| 690 |
+
|
| 691 |
+
if core.any():
|
| 692 |
+
# 8-connected reconstruction: follow each in-band seed down its fiber,
|
| 693 |
+
# out of the band, recovering the long vertical radials.
|
| 694 |
+
fiber = ndi.binary_propagation(core, mask=permissive,
|
| 695 |
+
structure=np.ones((3, 3), bool))
|
| 696 |
+
else:
|
| 697 |
+
# Empty-seed fallback: thin tubeness inside the gate. NEVER skeletonise
|
| 698 |
+
# the prob blob (that is the original honeycomb bug).
|
| 699 |
+
fiber = (T >= thr_hi) & gate & bright
|
| 700 |
+
|
| 701 |
+
fiber = remove_small_objects(fiber, int(min_object_px))
|
| 702 |
+
if not fiber.any():
|
| 703 |
+
return _empty()
|
| 704 |
+
# Bridge sub-pixel gaps without fusing adjacent parallel fibers.
|
| 705 |
+
fiber = remove_small_holes(fiber, area_threshold=4)
|
| 706 |
+
|
| 707 |
+
# 7. Skeletonise the THIN mask -> clean single-pixel centrelines.
|
| 708 |
+
dist = ndi.distance_transform_edt(fiber, sampling=(dy, dx)).astype(np.float32)
|
| 709 |
+
skel = skeletonize(fiber)
|
| 710 |
+
|
| 711 |
+
# 8. Artifact (filled-slab) rejection: drop skeleton pixels whose local
|
| 712 |
+
# half-width exceeds a real fiber -> removes any residual medial-axis mesh
|
| 713 |
+
# in the dense band, keyed on WIDTH (not orientation), so thin verticals
|
| 714 |
+
# are never touched.
|
| 715 |
+
max_hw = max(px, float(max_fiber_half_width_um))
|
| 716 |
+
skel = skel & (dist <= max_hw)
|
| 717 |
+
if not skel.any():
|
| 718 |
+
return _empty()
|
| 719 |
+
|
| 720 |
+
skel = skel[None]
|
| 721 |
+
if prune_um and prune_um > 0:
|
| 722 |
+
skel = prune_skeleton(skel, voxel, spur_um=float(prune_um))
|
| 723 |
+
return TraceResult(fiber[None], skel, dist[None], voxel)
|
| 724 |
+
|
| 725 |
+
|
| 726 |
# --------------------------------------------------------------------------- #
|
| 727 |
# Region definition (IHC vs OHC) from Myo7a
|
| 728 |
# --------------------------------------------------------------------------- #
|