File size: 18,160 Bytes
e97902e
 
 
 
 
 
 
 
 
 
 
bf63b9e
e97902e
bf63b9e
 
e97902e
 
bf63b9e
 
e97902e
bf63b9e
 
e97902e
 
 
 
bf63b9e
 
 
 
 
e97902e
 
 
bf63b9e
e97902e
bf63b9e
e97902e
 
 
 
bf63b9e
 
 
e97902e
 
 
bf63b9e
e97902e
 
 
 
bf63b9e
 
 
e97902e
bf63b9e
 
 
 
e97902e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf63b9e
e97902e
bf63b9e
 
 
e97902e
bf63b9e
e97902e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf63b9e
 
e97902e
 
 
bf63b9e
 
 
e97902e
bf63b9e
7be29ab
e97902e
 
 
 
 
 
 
 
bf63b9e
 
e97902e
 
 
 
 
bf63b9e
 
e97902e
 
bf63b9e
e97902e
 
 
 
 
 
 
bf63b9e
 
e97902e
 
bf63b9e
e97902e
bf63b9e
e97902e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf63b9e
e97902e
 
 
 
 
 
bf63b9e
e97902e
 
bf63b9e
e97902e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf63b9e
 
 
 
 
 
e97902e
bf63b9e
 
 
7be29ab
bf63b9e
 
e97902e
 
 
 
 
 
bf63b9e
 
e97902e
 
 
bf63b9e
 
 
e97902e
 
 
 
 
bf63b9e
e97902e
 
 
 
 
bf63b9e
e97902e
 
 
 
 
 
 
 
 
 
 
 
 
 
bf63b9e
e97902e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf63b9e
e97902e
 
 
 
 
 
bf63b9e
 
 
e97902e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf63b9e
e97902e
 
 
bf63b9e
 
e97902e
bf63b9e
 
 
e97902e
 
 
 
 
 
bf63b9e
 
e97902e
 
bf63b9e
e97902e
 
 
 
bf63b9e
 
 
7be29ab
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
"""Jolia — zero-shot CT analysis demo.

Upload a chest / abdominal CT volume (NIfTI) and score it against free-text
findings, either against the whole volume (global CLIP head) or routed to a
specific organ query (ParallelOrganCLIP head).

Mirrors `example_zero_shot.py` from the raidium/Jolia repo 1:1: same
preprocessing (`JoliaPreprocessor`), same paired text encoder
(Qwen3-Embedding-8B with last-token pooling), same calibrated logits.
"""

import os

os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")

import spaces  # noqa: F401  # must precede torch / any CUDA-touching import

import sys
import time

import gradio as gr
import nibabel as nib
import numpy as np
import pandas as pd
import torch
import torch.nn.functional as F
from huggingface_hub import snapshot_download
from transformers import AutoModel

JOLIA_ID = "raidium/Jolia"
TEXT_ID = "Qwen/Qwen3-Embedding-8B"
MAX_PROMPTS = 10
MAX_ORGANS = 16
CACHE_VERSION = "v1"

# The Jolia repo ships its own preprocessing / text-encoder helpers.
_repo = snapshot_download(JOLIA_ID)
if _repo not in sys.path:
    sys.path.insert(0, _repo)

from jolia_windowing import get_available_windows  # noqa: E402
from preprocessing_jolia import JoliaPreprocessor  # noqa: E402
from text_encoder_jolia import JoliaTextEncoder  # noqa: E402

PRE = JoliaPreprocessor()
CT_WINDOWS = get_available_windows("CT")  # channel order of the 11 windowing channels
PREVIEW_WINDOWS = ["auto", "lung", "mediastinum", "abdomen", "liver", "bone", "soft_tissue"]

print("[1/2] Loading Jolia vision backbone ...", flush=True)
JOLIA = AutoModel.from_pretrained(JOLIA_ID, trust_remote_code=True).eval().to("cuda")
print("[2/2] Loading paired text encoder Qwen3-Embedding-8B (~15 GB) ...", flush=True)
TEXT = (
    JoliaTextEncoder.from_pretrained(
        TEXT_ID,
        dtype=torch.bfloat16,
        context_length=JOLIA.config.text_context_length,
    )
    .eval()
    .to("cuda")
)
ORGAN_NAMES = list(JOLIA.organ_slot_names)
print(f"Ready — {len(ORGAN_NAMES)} organ slots available.", flush=True)

DEFAULT_ORGANS = [
    "lungs",
    "pleura",
    "heart",
    "mediastinum",
    "liver",
    "kidneys",
    "spleen",
    "pancreas",
    "spine",
]
DEFAULT_VOLUME_PROMPTS = "\n".join(
    [
        "a normal chest CT",
        "a chest CT showing a pulmonary nodule",
        "a chest CT showing pneumonia",
        "a chest CT showing pleural effusion",
        "a CT showing a liver lesion",
    ]
)
DEFAULT_ORGAN_PROMPTS = "\n".join(["looks normal", "a lesion", "a mass", "an enlarged organ"])


# ----------------------------------------------------------------------------
# CT loading / preprocessing
# ----------------------------------------------------------------------------
def _load_ct(path: str):
    """NIfTI file -> (volume (H, W, D) in HU, resolution (row, col, slice) mm, info)."""
    try:
        img = nib.load(path)
    except Exception as exc:  # noqa: BLE001
        raise gr.Error(f"Could not read this file as NIfTI ({exc}). Convert DICOM with dcm2niix first.")
    try:
        img = nib.as_closest_canonical(img)  # reorient to RAS+
    except Exception:  # noqa: BLE001
        pass

    arr = np.asanyarray(img.dataobj)
    while arr.ndim > 3:
        arr = arr[..., 0]
    if arr.ndim != 3:
        raise gr.Error(f"Expected a 3D volume, got shape {tuple(arr.shape)}.")
    arr = np.nan_to_num(arr.astype(np.float32), nan=-1024.0)
    zooms = [float(z) for z in img.header.get_zooms()[:3]]
    zooms = [z if z > 0 else 1.0 for z in zooms]

    # RAS+ (x->Right, y->Anterior, z->Superior) to the radiological axial layout
    # the checkpoint was trained on: rows anterior->posterior, columns
    # right->left, slices inferior->superior (PrepareVolume then flips depth).
    vol = np.ascontiguousarray(arr.transpose(1, 0, 2)[::-1, ::-1, :])
    resolution = (zooms[1], zooms[0], zooms[2])  # (row, col, slice) mm
    info = {
        "shape": tuple(int(s) for s in arr.shape),
        "spacing": tuple(round(z, 3) for z in zooms),
        "hu_range": (float(np.percentile(vol, 0.5)), float(np.percentile(vol, 99.5))),
        "z_coverage_mm": round(arr.shape[2] * zooms[2], 1),
    }
    return vol, resolution, info


def _auto_window(vol: np.ndarray) -> str:
    """Pick a sensible display window: lung if there is lung parenchyma, else abdomen."""
    lung_frac = float(np.mean((vol > -900.0) & (vol < -500.0)))
    return "lung" if lung_frac > 0.06 else "abdomen"


def _u8(plane: np.ndarray) -> np.ndarray:
    img = (np.clip(plane, 0.0, 1.0) * 255.0).astype(np.uint8)
    return np.repeat(np.repeat(img, 2, axis=0), 2, axis=1)  # 192 -> 384 px


def _preview_tiles(image: torch.Tensor, window: str) -> list:
    """Orthogonal previews of the exact 192**3 cube the model sees."""
    vol = image[CT_WINDOWS.index(window)].float().numpy()
    depth, height, width = vol.shape
    tiles = []
    for frac in (0.3, 0.5, 0.7):
        idx = int(round(frac * (depth - 1)))
        tiles.append((_u8(vol[idx]), f"axial · slice {idx}/{depth - 1}"))
    tiles.append((_u8(vol[:, height // 2, :]), "coronal · mid"))
    tiles.append((_u8(vol[:, :, width // 2]), "sagittal · mid"))
    return tiles


def _prep(path: str, preview_window: str):
    """Load + preprocess a CT and render previews. CPU only."""
    vol, resolution, info = _load_ct(path)
    window = _auto_window(vol) if preview_window == "auto" else preview_window
    image = PRE(vol, resolution=resolution)  # (11, 192, 192, 192) float32
    return image, _preview_tiles(image, window), info, window


def _volume_summary(info: dict, window: str, extra: str = "") -> str:
    sx, sy, sz = info["spacing"]
    lo, hi = info["hu_range"]
    return (
        f"**Volume** {info['shape'][0]}×{info['shape'][1]}×{info['shape'][2]} @ "
        f"{sx}×{sy}×{sz} mm · {info['z_coverage_mm']} mm cranio-caudal coverage · "
        f"HU p0.5–p99.5 {lo:.0f}{hi:.0f}  \n"
        f"**Model input** 11×192×192×192 (1.5 mm isotropic, centre crop) · preview window `{window}`"
        + (f"  \n{extra}" if extra else "")
    )


def _parse_lines(text: str, limit: int) -> list:
    lines = [ln.strip() for ln in (text or "").splitlines()]
    return [ln for ln in lines if ln][:limit]


# ----------------------------------------------------------------------------
# Inference
# ----------------------------------------------------------------------------
@spaces.GPU(duration=45)
def analyze(
    ct_file: str,
    volume_prompts: str = DEFAULT_VOLUME_PROMPTS,
    organ_prompts: str = DEFAULT_ORGAN_PROMPTS,
    organs: list = DEFAULT_ORGANS,
    preview_window: str = "auto",
):
    """Zero-shot classify a CT volume against free-text findings with Jolia.

    Args:
        ct_file: Path to a chest / abdominal CT volume in NIfTI format (.nii or .nii.gz).
        volume_prompts: Whole-volume prompts, one per line (global CLIP head).
        organ_prompts: Short findings phrases, one per line (per-organ CLIP head).
        organs: Organ query slots to route the findings phrases to.
        preview_window: CT display window for the preview images ("auto" picks lung or abdomen).

    Returns:
        Orthogonal previews of the model input, a volume summary, the global
        zero-shot table and the per-organ probability matrix.
    """
    if not ct_file:
        raise gr.Error("Upload a CT volume (NIfTI .nii / .nii.gz) first.")
    vol_prompts = _parse_lines(volume_prompts, MAX_PROMPTS)
    org_prompts = _parse_lines(organ_prompts, MAX_PROMPTS)
    organs = [o for o in (organs or []) if o in ORGAN_NAMES][:MAX_ORGANS]
    if not vol_prompts and not org_prompts:
        raise gr.Error("Enter at least one prompt.")

    t0 = time.perf_counter()
    image, tiles, info, window = _prep(ct_file, preview_window)
    t_prep = time.perf_counter() - t0

    t0 = time.perf_counter()
    with torch.no_grad():
        x = image.unsqueeze(0).to("cuda")
        cls, organ_queries = JOLIA.forward_with_queries(x)  # (1, 576), (1, slots, 576)
        image_emb = F.normalize(cls.float(), dim=-1, eps=1e-6)

        global_rows = []
        if vol_prompts:
            text_features = TEXT(vol_prompts).to(image_emb.device)  # (N, 4096)
            text_emb = JOLIA.encode_text(text_features)  # (N, 576)
            cosine = (image_emb @ text_emb.t())[0]
            logits = JOLIA.zero_shot_logits(image_emb, text_emb)[0]
            probs = torch.sigmoid(logits)
            global_rows = [
                [p, round(float(lg), 4), round(float(pr), 4), round(float(cs), 4)]
                for p, lg, pr, cs in zip(vol_prompts, logits, probs, cosine)
            ]
            global_rows.sort(key=lambda r: -r[1])

        organ_rows = []
        if org_prompts and organs:
            organ_text = TEXT(org_prompts).to(image_emb.device)
            organ_text_emb = JOLIA.encode_organ_text(organ_text)  # (N, 576)
            for name in organs:
                idx = ORGAN_NAMES.index(name)
                emb = F.normalize(organ_queries[:, idx, :].float(), dim=-1, eps=1e-6)
                scale = JOLIA.organ_logit_scale[idx].float().exp()
                bias = JOLIA.organ_text_bias[idx].float()
                logits = (emb @ organ_text_emb.t())[0] * scale + bias
                organ_rows.append([name] + [round(float(v), 4) for v in torch.sigmoid(logits)])
    t_gpu = time.perf_counter() - t0

    global_df = pd.DataFrame(
        global_rows or [["—", 0.0, 0.0, 0.0]],
        columns=["prompt", "calibrated logit", "match probability", "cosine"],
    )
    organ_df = pd.DataFrame(
        organ_rows or [["—"] + [0.0] * max(1, len(org_prompts))],
        columns=["organ"] + (org_prompts or ["—"]),
    )
    best = f"**Top whole-volume match** · `{global_rows[0][0]}` (p={global_rows[0][2]:.3f})  \n" if global_rows else ""
    summary = _volume_summary(
        info,
        window,
        f"{best}*preprocess {t_prep:.1f}s · encode + score {t_gpu:.1f}s*",
    )
    return tiles, summary, global_df, organ_df


def preview(ct_file: str, preview_window: str = "auto"):
    """Render orthogonal previews of the preprocessed CT volume (no GPU).

    Args:
        ct_file: Path to a CT volume in NIfTI format (.nii or .nii.gz).
        preview_window: CT display window ("auto" picks lung or abdomen).

    Returns:
        Preview images of the 192**3 model input and a short volume summary.
    """
    if not ct_file:
        return [], "Upload a CT volume (NIfTI `.nii` / `.nii.gz`) to get started."
    _, tiles, info, window = _prep(ct_file, preview_window)
    return tiles, _volume_summary(info, window, "*Preview only — press **Analyze** to score prompts.*")


# ----------------------------------------------------------------------------
# UI
# ----------------------------------------------------------------------------
CSS = """
#col-container { max-width: 1250px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""

with gr.Blocks(title="Jolia — zero-shot CT") as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(
            "# Jolia — zero-shot CT analysis\n"
            "[`raidium/Jolia`](https://huggingface.co/raidium/Jolia) is a 3D CT foundation model: it "
            "encodes a whole chest / abdominal CT into one global embedding **and** 102 named "
            "organ-query embeddings, both aligned with report text. Score any free-text finding "
            "against the whole volume, or route it to a single organ.\n\n"
            "⚠️ Research preview — **not a medical device, not for clinical use.**"
        )
        with gr.Row():
            with gr.Column(scale=4):
                ct_file = gr.File(
                    label="CT volume (NIfTI .nii / .nii.gz)",
                    file_types=[".nii", ".gz"],
                    type="filepath",
                )
                volume_prompts = gr.Textbox(
                    label="Whole-volume prompts (one per line)",
                    info="Scored against the global CLIP head — full sentences work best.",
                    value=DEFAULT_VOLUME_PROMPTS,
                    lines=5,
                )
                organ_prompts = gr.Textbox(
                    label="Per-organ findings phrases (one per line)",
                    info="Scored against the per-organ head — short phrases work best.",
                    value=DEFAULT_ORGAN_PROMPTS,
                    lines=4,
                )
                organs = gr.Dropdown(
                    label="Organ query slots",
                    choices=ORGAN_NAMES,
                    value=DEFAULT_ORGANS,
                    multiselect=True,
                    max_choices=MAX_ORGANS,
                )
                run = gr.Button("Analyze", variant="primary")
                with gr.Accordion("Advanced", open=False):
                    preview_window = gr.Dropdown(
                        label="Preview window",
                        choices=PREVIEW_WINDOWS,
                        value="auto",
                        info="Display only — the model always sees all 11 windowing channels.",
                    )
            with gr.Column(scale=6):
                gallery = gr.Gallery(
                    label="Model input (1.5 mm isotropic, 192³ centre crop)",
                    columns=3,
                    height=340,
                    object_fit="contain",
                )
                summary = gr.Markdown("Upload a CT volume (NIfTI `.nii` / `.nii.gz`) to get started.")
                global_df = gr.Dataframe(
                    label="Whole-volume zero-shot (global CLIP head)",
                    headers=["prompt", "calibrated logit", "match probability", "cosine"],
                    wrap=True,
                )
                organ_df = gr.Dataframe(
                    label="Per-organ zero-shot — match probability per (organ, phrase)",
                    wrap=True,
                )

        gr.Markdown(
            "### Examples\n"
            "Public CT volumes from the [TotalSegmentator dataset](https://zenodo.org/records/10047292) "
            "(Wasserthal et al., CC-BY-4.0), via "
            "[`YongchengYAO/TotalSegmentator-CT-Lite`](https://huggingface.co/datasets/YongchengYAO/TotalSegmentator-CT-Lite). "
            "Radiology labels in the file names come from that dataset's metadata."
        )
        gr.Examples(
            examples=[
                [
                    "examples/chest_ct_lung_tumor_s1173.nii.gz",
                    "\n".join(
                        [
                            "a normal chest CT",
                            "a chest CT showing a pulmonary nodule",
                            "a chest CT showing pneumonia",
                            "a chest CT showing pleural effusion",
                            "a chest CT showing emphysema",
                        ]
                    ),
                    "\n".join(["looks normal", "a nodule", "a mass", "an effusion"]),
                ],
                [
                    "examples/chest_ct_inflammation_s1353.nii.gz",
                    "\n".join(
                        [
                            "a normal chest CT",
                            "a chest CT showing pneumonia",
                            "a chest CT showing consolidation",
                            "a chest CT showing a pulmonary nodule",
                        ]
                    ),
                    "\n".join(["looks normal", "consolidation", "an infection", "a nodule"]),
                ],
                [
                    "examples/abdomen_pelvis_ct_normal_s0143.nii.gz",
                    "\n".join(
                        [
                            "a normal abdominal CT",
                            "an abdominal CT showing a liver lesion",
                            "an abdominal CT showing hepatic steatosis",
                            "an abdominal CT showing bowel obstruction",
                        ]
                    ),
                    "\n".join(["looks normal", "a lesion", "an enlarged organ"]),
                ],
                [
                    "examples/abdomen_ct_tumor_s0168.nii.gz",
                    "\n".join(
                        [
                            "a normal abdominal CT",
                            "an abdominal CT showing a tumour",
                            "an abdominal CT showing a liver lesion",
                            "an abdominal CT showing enlarged lymph nodes",
                        ]
                    ),
                    "\n".join(["looks normal", "a lesion", "a mass", "an enlarged organ"]),
                ],
            ],
            inputs=[ct_file, volume_prompts, organ_prompts],
            outputs=[gallery, summary, global_df, organ_df],
            fn=analyze,
            cache_examples=True,
            cache_mode="lazy",
            label=f"Example CT volumes ({CACHE_VERSION})",
        )

        gr.Markdown(
            "Whole-volume scores use Jolia's global CLIP head; per-organ scores route the phrase to "
            "one organ query through the ParallelOrganCLIP head (each organ has its own trained "
            "temperature and bias). Probabilities are `sigmoid(calibrated logit)` — a per-pair "
            '"is this a match?" score, not a softmax over prompts, so they do not sum to 1. '
            "Text is encoded with the paired [`Qwen/Qwen3-Embedding-8B`](https://huggingface.co/Qwen/Qwen3-Embedding-8B) "
            "(last-token pooling, context length 512). DICOM series can be converted with `dcm2niix`."
        )

    ct_file.change(preview, inputs=[ct_file, preview_window], outputs=[gallery, summary])
    preview_window.change(preview, inputs=[ct_file, preview_window], outputs=[gallery, summary])
    run.click(
        analyze,
        inputs=[ct_file, volume_prompts, organ_prompts, organs, preview_window],
        outputs=[gallery, summary, global_df, organ_df],
        api_name="analyze",
    )

if __name__ == "__main__":
    demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)