File size: 7,150 Bytes
e4a7008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""MedCLIPSeg-style text-prompted segmentation, adapted to run here.

Faithful to the *approach* of Koleilat et al., "MedCLIPSeg: Probabilistic
Vision-Language Adaptation ..." (CVPR 2026): a FROZEN vision-language (CLIP)
backbone provides patch-level embeddings; short TEXT PROMPTS describe the target
("nerve fibers ...") and the background; patch-text similarity yields a dense
segmentation probability, and a PROBABILISTIC step (Monte-Carlo sampling of the
patch embedding with an estimated variance — the paper samples attention Values
from learned distributions) yields a pixel-level UNCERTAINTY map.

This module is ZERO-SHOT (no training) using a general CLIP backbone, so on
cochlear neurofilament it is a *coarse prior*, not a trained segmentor. For the
real, trained model (the paper's PVL adapters + decoder, fine-tuned on masks)
see reference_medclipseg/ and train it on a GPU with the data from
prepare_medclip_data.py.
"""
import os
import numpy as np
import torch
import torch.nn as nn
from skimage.transform import resize

_MODEL = "openai/clip-vit-base-patch16"      # 16-px patches -> 14x14 per 224 tile
_clip = None
_proc = None

FG_PROMPTS = [
    "a fluorescence microscopy image of nerve fibers",
    "neurofilament nerve fibers and axons",
    "a dense network of thin bright nerve fibers",
]
BG_PROMPTS = [
    "a black empty background",
    "dark region with no tissue",
    "background noise",
]


def _load():
    global _clip, _proc
    if _clip is None:
        from transformers import CLIPModel, CLIPProcessor
        _clip = CLIPModel.from_pretrained(_MODEL).eval()
        _proc = CLIPProcessor.from_pretrained(_MODEL)
    return _clip, _proc


def _text_embeds(prompts):
    clip, proc = _load()
    tok = proc(text=prompts, return_tensors="pt", padding=True)
    with torch.no_grad():
        out = clip.text_model(input_ids=tok["input_ids"],
                              attention_mask=tok.get("attention_mask"))
        t = clip.text_projection(out.pooler_output)
    return t / t.norm(dim=-1, keepdim=True)                 # (K, D)


def _dense_patch_embeds(win_u8):
    """Per-patch CLIP embeddings for a 224x224 uint8 RGB window (MaskCLIP-style)."""
    clip, proc = _load()
    px = proc(images=win_u8, return_tensors="pt")["pixel_values"]
    vm = clip.vision_model
    with torch.no_grad():
        toks = vm(pixel_values=px).last_hidden_state[:, 1:, :]   # drop CLS -> (1,196,768)
        emb = clip.visual_projection(vm.post_layernorm(toks))     # (1,196,D)
    emb = emb / emb.norm(dim=-1, keepdim=True)
    return emb[0]                                             # (196, D)


def segment(mip_u8, n_mc=8, sigma=0.05, tile=224):
    """Text-prompted CLIP segmentation of a grayscale MIP.

    Returns (prob, uncertainty) float maps at the input resolution, both in
    [0, 1]. ``prob`` is the fiber probability; ``uncertainty`` is the std of the
    probability across ``n_mc`` Monte-Carlo embedding samples.
    """
    H0, W0 = mip_u8.shape
    rows = max(1, round(H0 / tile))
    cols = max(1, round(W0 / tile))
    Hr, Wr = rows * tile, cols * tile
    rgb = np.stack([resize(mip_u8, (Hr, Wr), preserve_range=True)] * 3, -1).astype(np.uint8)

    fg_t, bg_t = _text_embeds(FG_PROMPTS), _text_embeds(BG_PROMPTS)
    gh, gw = 14, 14                                          # patches per tile (224/16)
    prob = np.zeros((rows * gh, cols * gw), np.float32)
    unc = np.zeros((rows * gh, cols * gw), np.float32)
    for r in range(rows):
        for c in range(cols):
            win = rgb[r * tile:(r + 1) * tile, c * tile:(c + 1) * tile]
            emb = _dense_patch_embeds(win)                  # (196, D)
            samples = []
            for m in range(n_mc):
                e = emb if m == 0 else torch.nn.functional.normalize(
                    emb + sigma * torch.randn_like(emb), dim=-1)
                fg = (e @ fg_t.T).mean(-1)                   # (196,)
                bg = (e @ bg_t.T).mean(-1)
                samples.append(torch.sigmoid((fg - bg) / 0.1).numpy())
            samples = np.stack(samples).reshape(n_mc, gh, gw)
            prob[r * gh:(r + 1) * gh, c * gw:(c + 1) * gw] = samples.mean(0)
            unc[r * gh:(r + 1) * gh, c * gw:(c + 1) * gw] = samples.std(0)
    prob = resize(prob, (H0, W0), order=1, preserve_range=True)
    unc = resize(unc, (H0, W0), order=1, preserve_range=True)
    # normalise uncertainty to [0,1] for display
    unc = (unc - unc.min()) / (np.ptp(unc) + 1e-6)
    return prob.astype(np.float32), unc.astype(np.float32)


# --------------------------------------------------------------------------- #
#  Few-shot TRAINED decoder (frozen CLIP + text-conditioned decoder).
#  Trained with train_medclip_fewshot.py (8 animals train, C1/C2b test).
# --------------------------------------------------------------------------- #

_WEIGHTS = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                        "weights_medclip_decoder.pt")
_decoder = None


class _Decoder(nn.Module):
    def __init__(self, cin=768, tdim=512, C=128):
        super().__init__()
        self.reduce = nn.Conv2d(cin, C, 1)
        self.film = nn.Linear(tdim, 2 * C)

        def up(ci, co):
            return nn.Sequential(nn.ConvTranspose2d(ci, co, 2, 2),
                                 nn.Conv2d(co, co, 3, padding=1),
                                 nn.BatchNorm2d(co), nn.ReLU(True))
        self.up = nn.Sequential(up(C, C), up(C, C), up(C, C // 2), up(C // 2, C // 4))
        self.out = nn.Conv2d(C // 4, 1, 1)

    def forward(self, feat, tvec):
        x = self.reduce(feat)
        g, b = self.film(tvec).chunk(2, -1)
        x = x * (1 + g[:, :, None, None]) + b[:, :, None, None]
        return torch.sigmoid(self.out(self.up(x)))


def _clip_patch_grid(mip_u8, size=224):
    clip, proc = _load()
    rgb = np.stack([resize(mip_u8, (size, size), preserve_range=True)] * 3, -1).astype(np.uint8)
    px = proc(images=rgb, return_tensors="pt")["pixel_values"]
    with torch.no_grad():
        toks = clip.vision_model(pixel_values=px).last_hidden_state[0, 1:, :]
    g = int(round(toks.shape[0] ** 0.5))
    return toks.reshape(g, g, -1).permute(2, 0, 1).contiguous()[None]     # (1,768,g,g)


def has_trained_model():
    return os.path.exists(_WEIGHTS)


def segment_trained(mip_u8):
    """Segment with the few-shot TRAINED decoder. Returns (prob, uncertainty)."""
    global _decoder
    if _decoder is None:
        _decoder = _Decoder().eval()
        _decoder.load_state_dict(torch.load(_WEIGHTS, map_location="cpu")["model"])
    tvec = _text_embeds(FG_PROMPTS).mean(0, keepdim=True)
    feat = _clip_patch_grid(mip_u8)
    with torch.no_grad():
        prob = _decoder(feat, tvec)[0, 0].numpy()
    H0, W0 = mip_u8.shape
    prob = resize(prob, (H0, W0), order=1, preserve_range=True).astype(np.float32)
    unc = np.clip(4 * prob * (1 - prob), 0, 1).astype(np.float32)         # entropy-like
    return prob, unc


def segment_best(mip_u8):
    """Use the trained few-shot decoder if available, else zero-shot CLIP."""
    if has_trained_model():
        return segment_trained(mip_u8)
    return segment(mip_u8)