multimodalart HF Staff commited on
Commit
e97902e
·
verified ·
1 Parent(s): 7f793f3

Jolia zero-shot CT demo

Browse files
README.md CHANGED
@@ -1,38 +1,52 @@
1
  ---
2
- title: Jolia 3D CT Zero-Shot
3
  emoji: 🫁
4
  colorFrom: red
5
  colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.22.0
8
  app_file: app.py
9
- short_description: Zero-shot 3D CT classification with Jolia
10
  python_version: "3.12"
11
  startup_duration_timeout: 1h
12
  pinned: false
 
13
  ---
14
 
15
- # 🫁 Jolia — 3D CT Foundation Model (Zero-Shot)
16
 
17
- Interactive demo of [`raidium/Jolia`](https://huggingface.co/raidium/Jolia), a 3D CT foundation
18
- model that encodes a whole CT volume into a global embedding plus 102 per-organ query embeddings,
19
- all aligned with radiology-report text via CLIP.
20
 
21
- Upload a chest/abdominal CT volume (NIfTI `.nii` / `.nii.gz`) and score it against arbitrary text
22
- prompts — no fine-tuning:
23
 
24
- - **Global zero-shot** the whole-volume embedding vs each text prompt (calibrated CLIP logits → match probability).
25
- - **Per-organ zero-shot** — routes short findings phrases to a specific organ's query embedding via the ParallelOrganCLIP head.
 
26
 
27
- Paired text encoder: [`Qwen/Qwen3-Embedding-8B`](https://huggingface.co/Qwen/Qwen3-Embedding-8B) (bf16).
 
 
 
28
 
29
- ## Example data
30
 
31
- The bundled example CT volumes come from the **TotalSegmentator** dataset
32
- ([MedOtter/totalsegmentator-organs](https://huggingface.co/datasets/MedOtter/totalsegmentator-organs),
33
- CC-BY-4.0; Wasserthal et al., *Radiology: Artificial Intelligence* 2023).
34
 
35
- ## Disclaimer
 
 
 
36
 
37
- ⚠️ Research preview. **Not a medical device and not for clinical use.** Jolia is a feature extractor
38
- for research on adult chest/abdominal CT and does not produce diagnoses.
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Jolia
3
  emoji: 🫁
4
  colorFrom: red
5
  colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.22.0
8
  app_file: app.py
9
+ short_description: Zero-shot CT findings with the Jolia 3D CT foundation model
10
  python_version: "3.12"
11
  startup_duration_timeout: 1h
12
  pinned: false
13
+ license: other
14
  ---
15
 
16
+ # Jolia — zero-shot CT analysis
17
 
18
+ Demo of [`raidium/Jolia`](https://huggingface.co/raidium/Jolia), a 3D CT foundation model that
19
+ encodes a whole chest / abdominal CT volume into a global embedding **and** 102 named organ-query
20
+ embeddings, both aligned with radiology-report text.
21
 
22
+ Upload a NIfTI CT volume and:
 
23
 
24
+ - score free-text findings against the **whole volume** (global CLIP head), and
25
+ - route short findings phrases to a **single organ query** (ParallelOrganCLIP head, each organ with
26
+ its own trained temperature and bias).
27
 
28
+ The pipeline follows `example_zero_shot.py` from the model repo exactly: `JoliaPreprocessor`
29
+ (1.5 mm isotropic, 192³ centre crop, 11 CT windowing channels) for the image, and the paired
30
+ [`Qwen/Qwen3-Embedding-8B`](https://huggingface.co/Qwen/Qwen3-Embedding-8B) text encoder
31
+ (last-token pooling, context length 512) for the prompts.
32
 
33
+ > ⚠️ Research preview. Not a medical device; not for clinical use.
34
 
35
+ ## Example volumes
 
 
36
 
37
+ The bundled example CTs come from the **TotalSegmentator dataset**
38
+ (Wasserthal et al., [Zenodo record 10047292](https://zenodo.org/records/10047292), **CC-BY-4.0**),
39
+ downloaded via [`YongchengYAO/TotalSegmentator-CT-Lite`](https://huggingface.co/datasets/YongchengYAO/TotalSegmentator-CT-Lite).
40
+ File names carry that dataset's own study-type / pathology metadata. Attribution:
41
 
42
+ > Wasserthal, J. et al. *TotalSegmentator: Robust segmentation of 104 anatomic structures in CT
43
+ > images.* Radiology: Artificial Intelligence (2023). Dataset licensed CC-BY-4.0.
44
+
45
+ ## Notes
46
+
47
+ - Volumes are reoriented to the radiological axial layout (rows anterior→posterior, columns
48
+ right→left, slices inferior→superior) before `JoliaPreprocessor`, which then flips depth and
49
+ centre-crops.
50
+ - Probabilities are `sigmoid(calibrated logit)` — a per-pair "is this a match?" score, not a
51
+ softmax over prompts.
52
+ - DICOM series can be converted to NIfTI with `dcm2niix`.
app.py CHANGED
@@ -1,280 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
1
  import os
 
2
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
3
 
 
 
4
  import sys
5
  import time
6
- import numpy as np
7
- import spaces
8
- import torch
9
  import gradio as gr
10
  import nibabel as nib
 
 
 
 
11
  from huggingface_hub import snapshot_download
12
  from transformers import AutoModel
13
 
14
- # ----------------------------------------------------------------------------
15
- # Model loading (module scope, eager .to("cuda") for ZeroGPU)
16
- # ----------------------------------------------------------------------------
17
  JOLIA_ID = "raidium/Jolia"
18
  TEXT_ID = "Qwen/Qwen3-Embedding-8B"
 
 
 
19
 
20
- # Jolia ships its preprocessing + text-encoder helpers as plain modules in the
21
- # repo. Grab them via snapshot_download and put them on sys.path.
22
  _repo = snapshot_download(JOLIA_ID)
23
- sys.path.append(_repo)
 
 
 
24
  from preprocessing_jolia import JoliaPreprocessor # noqa: E402
25
  from text_encoder_jolia import JoliaTextEncoder # noqa: E402
26
 
27
- print("Loading Jolia vision backbone ...")
28
- jolia = AutoModel.from_pretrained(JOLIA_ID, trust_remote_code=True).eval().to("cuda")
 
29
 
30
- print("Loading paired text encoder (Qwen3-Embedding-8B, bf16) ...")
31
- text_encoder = (
 
 
32
  JoliaTextEncoder.from_pretrained(
33
  TEXT_ID,
34
  dtype=torch.bfloat16,
35
- context_length=jolia.config.text_context_length,
36
  )
37
  .eval()
38
  .to("cuda")
39
  )
40
-
41
- preprocessor = JoliaPreprocessor()
42
- ORGAN_NAMES = jolia.organ_slot_names # 102 named organ slots
43
- print(f"Ready. {len(ORGAN_NAMES)} organ slots available.")
44
-
45
- DEFAULT_PROMPTS = (
46
- "a chest CT showing pneumonia\n"
47
- "a CT with a lung nodule\n"
48
- "a CT showing pleural effusion\n"
49
- "a normal chest CT\n"
50
- "a CT showing cardiomegaly"
 
 
 
 
 
 
 
 
 
 
 
51
  )
52
- DEFAULT_ORGANS = ["lungs", "heart", "liver", "spleen", "kidneys", "pancreas"]
53
- DEFAULT_ORGAN_PROMPTS = "a lesion\nan enlarged organ\nlooks normal"
54
 
55
 
56
  # ----------------------------------------------------------------------------
57
- # NIfTI reading + light preview (CPU, cheap)
58
  # ----------------------------------------------------------------------------
59
- def _read_nifti(path):
60
- """Load a NIfTI CT volume -> (volume float32 (H,W,D), spacing (r,c,s) mm)."""
61
- img = nib.load(path)
62
- vol = np.asarray(img.get_fdata(), dtype=np.float32)
63
- if vol.ndim != 3:
64
- raise gr.Error(
65
- f"Expected a 3D CT volume, got shape {vol.shape}. "
66
- "Upload a single-series CT scan in NIfTI (.nii / .nii.gz) format."
67
- )
68
- zooms = img.header.get_zooms()[:3]
69
- spacing = tuple(float(z) if z and z > 0 else 1.0 for z in zooms)
70
- return vol, spacing
71
-
72
-
73
- def _window(slice_hu, center=40.0, width=400.0):
74
- """Apply a CT window (default soft-tissue) and map to 0-255 uint8."""
75
- lo, hi = center - width / 2.0, center + width / 2.0
76
- s = np.clip((slice_hu - lo) / (hi - lo), 0.0, 1.0)
77
- return (s * 255.0).astype(np.uint8)
78
-
79
-
80
- def preview_nifti(path):
81
- """Render 3 mid-slices (axial / coronal / sagittal) of the uploaded CT."""
82
- if not path:
83
- return None
84
- vol, spacing = _read_nifti(path)
85
- # vol is (H, W, D). Build a simple montage of mid slices.
86
- h, w, d = vol.shape
87
- ax = np.rot90(_window(vol[:, :, d // 2])) # axial
88
- cor = np.rot90(_window(vol[:, w // 2, :])) # coronal
89
- sag = np.rot90(_window(vol[h // 2, :, :])) # sagittal
90
- # Pad to same height and concat side by side.
91
- hh = max(ax.shape[0], cor.shape[0], sag.shape[0])
92
-
93
- def _pad(a):
94
- pad = hh - a.shape[0]
95
- if pad > 0:
96
- a = np.pad(a, ((0, pad), (0, 0)), constant_values=0)
97
- return a
98
-
99
- montage = np.concatenate([_pad(ax), _pad(cor), _pad(sag)], axis=1)
100
- return montage
101
-
102
-
103
- def _parse_prompts(text):
104
- return [ln.strip() for ln in (text or "").replace(",", "\n").splitlines() if ln.strip()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
 
107
- def _estimate_duration(nifti_path, prompts_text, organs, organ_prompts_text, *args, **kwargs):
108
- n_prompts = max(1, len(_parse_prompts(prompts_text)) + len(_parse_prompts(organ_prompts_text)))
109
- n_organs = max(1, len(organs or []))
110
- # Measured GPU compute is ~2s; the rest is worker cold-start + I/O headroom.
111
- return min(90, 20 + n_prompts + n_organs)
112
 
113
 
114
  # ----------------------------------------------------------------------------
115
- # Inference (GPU)
116
  # ----------------------------------------------------------------------------
117
- @spaces.GPU(duration=_estimate_duration)
118
- def classify(nifti_path, prompts_text, organs, organ_prompts_text,
119
- progress=gr.Progress(track_tqdm=True)):
120
- """Zero-shot classification of a 3D CT volume with the Jolia foundation model.
 
 
 
 
 
121
 
122
  Args:
123
- nifti_path: path to a CT volume in NIfTI (.nii/.nii.gz) format.
124
- prompts_text: newline/comma separated text prompts scored against the
125
- whole-volume (global) embedding.
126
- organs: list of organ names to run per-organ (query-routed) zero-shot on.
127
- organ_prompts_text: short findings phrases scored per organ.
128
 
129
  Returns:
130
- A global-classification table and a per-organ score table.
 
131
  """
132
- if not nifti_path:
133
- raise gr.Error("Please upload a CT volume (.nii or .nii.gz) first.")
134
-
135
- prompts = _parse_prompts(prompts_text)
136
- if not prompts:
137
- raise gr.Error("Please enter at least one text prompt for global classification.")
138
- organ_prompts = _parse_prompts(organ_prompts_text)
139
 
140
  t0 = time.perf_counter()
141
- vol, spacing = _read_nifti(nifti_path)
142
- image = preprocessor(vol, resolution=spacing).unsqueeze(0).to("cuda")
143
 
 
144
  with torch.no_grad():
145
- # --- Global zero-shot ---
146
- text_features = text_encoder(prompts).to("cuda")
147
- logits = jolia.zero_shot(image, text_features) # (1, N) calibrated
148
- probs = torch.sigmoid(logits)[0].float().cpu().numpy()
149
- cosine = jolia.zero_shot(image, text_features, calibrated=False)[0].float().cpu().numpy()
150
-
151
- order = np.argsort(-probs)
152
- global_rows = [
153
- [prompts[i], round(float(probs[i]), 4), round(float(cosine[i]), 4)]
154
- for i in order
155
- ]
156
-
157
- # --- Per-organ zero-shot ---
158
- organ_rows = []
159
- if organs and organ_prompts:
160
- with torch.no_grad():
161
- organ_text = text_encoder(organ_prompts).to("cuda")
162
- per_organ = jolia.zero_shot_organs(image, organ_text, organs=list(organs))
163
- for organ in organs:
164
- lg = per_organ[organ][0].float().cpu().numpy()
165
- pr = 1.0 / (1.0 + np.exp(-lg))
166
- row = [organ] + [round(float(x), 4) for x in pr]
167
- organ_rows.append(row)
168
-
169
- elapsed = time.perf_counter() - t0
170
- organ_headers = ["organ"] + (organ_prompts if organ_prompts else ["(no prompts)"])
171
- info = (
172
- f"Volume {vol.shape} @ {tuple(round(s, 2) for s in spacing)} mm • "
173
- f"{len(prompts)} global prompt(s), {len(organs or [])} organ(s) • "
174
- f"{elapsed:.1f}s"
 
 
 
175
  )
176
- return (
177
- gr.update(value=global_rows),
178
- gr.update(value=organ_rows, headers=organ_headers),
 
 
 
179
  info,
 
 
180
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
 
183
  # ----------------------------------------------------------------------------
184
  # UI
185
  # ----------------------------------------------------------------------------
186
  CSS = """
187
- #col-container { max-width: 1150px; margin: 0 auto; }
188
  .dark .gradio-container { color: var(--body-text-color); }
189
  """
190
 
191
- with gr.Blocks(title="Jolia — 3D CT Zero-Shot") as demo:
192
  with gr.Column(elem_id="col-container"):
193
  gr.Markdown(
194
- """
195
- # 🫁 Jolia 3D CT Foundation Model (Zero-Shot)
196
-
197
- [`raidium/Jolia`](https://huggingface.co/raidium/Jolia) is a 3D CT foundation model that
198
- encodes a whole CT volume into a global embedding + 102 per-organ query embeddings, aligned
199
- with radiology-report text via CLIP. Upload a chest/abdominal CT volume and score it against
200
- **arbitrary text prompts** — no fine-tuning — both globally and per organ.
201
-
202
- Paired text encoder: `Qwen/Qwen3-Embedding-8B`.
203
- ⚠️ **Research preview — not a medical device, not for clinical use.**
204
- """
205
  )
206
-
207
  with gr.Row():
208
- with gr.Column(scale=1):
209
- nifti = gr.File(
210
- label="CT volume (NIfTI: .nii / .nii.gz)",
211
  file_types=[".nii", ".gz"],
212
  type="filepath",
213
  )
214
- preview = gr.Image(
215
- label="Preview (axial · coronal · sagittal, soft-tissue window)",
216
- height=240,
 
 
217
  )
218
- run = gr.Button("Run zero-shot classification", variant="primary")
219
- with gr.Column(scale=1):
220
- prompts_box = gr.Textbox(
221
- label="Global prompts (one per line)",
222
- value=DEFAULT_PROMPTS,
223
- lines=6,
224
  )
225
- with gr.Accordion("Per-organ zero-shot (query-routed)", open=True):
226
- organs_box = gr.Dropdown(
227
- label="Organs to score",
228
- choices=ORGAN_NAMES,
229
- value=DEFAULT_ORGANS,
230
- multiselect=True,
231
- )
232
- organ_prompts_box = gr.Textbox(
233
- label="Per-organ findings phrases (one per line)",
234
- value=DEFAULT_ORGAN_PROMPTS,
235
- lines=3,
 
 
 
236
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
- info_out = gr.Markdown()
239
- global_out = gr.Dataframe(
240
- headers=["prompt", "match probability", "cosine"],
241
- label="Global zero-shot whole-volume embedding vs text (sorted by probability)",
242
- wrap=True,
243
- )
244
- organ_out = gr.Dataframe(
245
- label="Per-organ zero-shot — organ-query embedding vs findings text (match probability)",
246
- wrap=True,
247
  )
248
-
249
  gr.Examples(
250
  examples=[
251
- ["examples/s0000_ct.nii.gz", DEFAULT_PROMPTS, DEFAULT_ORGANS, DEFAULT_ORGAN_PROMPTS],
252
- ["examples/s0004_ct.nii.gz", DEFAULT_PROMPTS, DEFAULT_ORGANS, DEFAULT_ORGAN_PROMPTS],
253
- ["examples/s0010_ct.nii.gz", DEFAULT_PROMPTS, DEFAULT_ORGANS, DEFAULT_ORGAN_PROMPTS],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  ],
255
- inputs=[nifti, prompts_box, organs_box, organ_prompts_box],
256
- outputs=[global_out, organ_out, info_out],
257
- fn=classify,
258
  cache_examples=True,
259
  cache_mode="lazy",
 
260
  )
261
 
262
  gr.Markdown(
263
- """
264
- ---
265
- Example CT volumes from the **TotalSegmentator** dataset
266
- ([MedOtter/totalsegmentator-organs](https://huggingface.co/datasets/MedOtter/totalsegmentator-organs),
267
- CC-BY-4.0; Wasserthal et al.). Model: [raidium/Jolia](https://huggingface.co/raidium/Jolia).
268
- """
269
  )
270
 
271
- nifti.change(fn=preview_nifti, inputs=nifti, outputs=preview)
 
272
  run.click(
273
- fn=classify,
274
- inputs=[nifti, prompts_box, organs_box, organ_prompts_box],
275
- outputs=[global_out, organ_out, info_out],
276
- api_name="classify",
277
  )
278
 
279
  if __name__ == "__main__":
280
- demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)
 
1
+ """Jolia — zero-shot CT analysis demo.
2
+
3
+ Upload a chest / abdominal CT volume (NIfTI) and score it against free-text
4
+ findings, either against the whole volume (global CLIP head) or routed to a
5
+ specific organ query (ParallelOrganCLIP head).
6
+
7
+ Mirrors `example_zero_shot.py` from the raidium/Jolia repo 1:1: same
8
+ preprocessing (`JoliaPreprocessor`), same paired text encoder
9
+ (Qwen3-Embedding-8B with last-token pooling), same calibrated logits.
10
+ """
11
+
12
  import os
13
+
14
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
15
 
16
+ import spaces # noqa: F401 # must precede torch / any CUDA-touching import
17
+
18
  import sys
19
  import time
20
+
 
 
21
  import gradio as gr
22
  import nibabel as nib
23
+ import numpy as np
24
+ import pandas as pd
25
+ import torch
26
+ import torch.nn.functional as F
27
  from huggingface_hub import snapshot_download
28
  from transformers import AutoModel
29
 
 
 
 
30
  JOLIA_ID = "raidium/Jolia"
31
  TEXT_ID = "Qwen/Qwen3-Embedding-8B"
32
+ MAX_PROMPTS = 10
33
+ MAX_ORGANS = 16
34
+ CACHE_VERSION = "v1"
35
 
36
+ # The Jolia repo ships its own preprocessing / text-encoder helpers.
 
37
  _repo = snapshot_download(JOLIA_ID)
38
+ if _repo not in sys.path:
39
+ sys.path.insert(0, _repo)
40
+
41
+ from jolia_windowing import get_available_windows # noqa: E402
42
  from preprocessing_jolia import JoliaPreprocessor # noqa: E402
43
  from text_encoder_jolia import JoliaTextEncoder # noqa: E402
44
 
45
+ PRE = JoliaPreprocessor()
46
+ CT_WINDOWS = get_available_windows("CT") # channel order of the 11 windowing channels
47
+ PREVIEW_WINDOWS = ["auto", "lung", "mediastinum", "abdomen", "liver", "bone", "soft_tissue"]
48
 
49
+ print("[1/2] Loading Jolia vision backbone ...", flush=True)
50
+ JOLIA = AutoModel.from_pretrained(JOLIA_ID, trust_remote_code=True).eval().to("cuda")
51
+ print("[2/2] Loading paired text encoder Qwen3-Embedding-8B (~15 GB) ...", flush=True)
52
+ TEXT = (
53
  JoliaTextEncoder.from_pretrained(
54
  TEXT_ID,
55
  dtype=torch.bfloat16,
56
+ context_length=JOLIA.config.text_context_length,
57
  )
58
  .eval()
59
  .to("cuda")
60
  )
61
+ ORGAN_NAMES = list(JOLIA.organ_slot_names)
62
+ print(f"Ready {len(ORGAN_NAMES)} organ slots available.", flush=True)
63
+
64
+ DEFAULT_ORGANS = [
65
+ "lungs",
66
+ "pleura",
67
+ "heart",
68
+ "mediastinum",
69
+ "liver",
70
+ "kidneys",
71
+ "spleen",
72
+ "pancreas",
73
+ "spine",
74
+ ]
75
+ DEFAULT_VOLUME_PROMPTS = "\n".join(
76
+ [
77
+ "a normal chest CT",
78
+ "a chest CT showing a pulmonary nodule",
79
+ "a chest CT showing pneumonia",
80
+ "a chest CT showing pleural effusion",
81
+ "a CT showing a liver lesion",
82
+ ]
83
  )
84
+ DEFAULT_ORGAN_PROMPTS = "\n".join(["looks normal", "a lesion", "a mass", "an enlarged organ"])
 
85
 
86
 
87
  # ----------------------------------------------------------------------------
88
+ # CT loading / preprocessing
89
  # ----------------------------------------------------------------------------
90
+ def _load_ct(path: str):
91
+ """NIfTI file -> (volume (H, W, D) in HU, resolution (row, col, slice) mm, info)."""
92
+ try:
93
+ img = nib.load(path)
94
+ except Exception as exc: # noqa: BLE001
95
+ raise gr.Error(f"Could not read this file as NIfTI ({exc}). Convert DICOM with dcm2niix first.")
96
+ try:
97
+ img = nib.as_closest_canonical(img) # reorient to RAS+
98
+ except Exception: # noqa: BLE001
99
+ pass
100
+
101
+ arr = np.asanyarray(img.dataobj)
102
+ while arr.ndim > 3:
103
+ arr = arr[..., 0]
104
+ if arr.ndim != 3:
105
+ raise gr.Error(f"Expected a 3D volume, got shape {tuple(arr.shape)}.")
106
+ arr = np.nan_to_num(arr.astype(np.float32), nan=-1024.0)
107
+ zooms = [float(z) for z in img.header.get_zooms()[:3]]
108
+ zooms = [z if z > 0 else 1.0 for z in zooms]
109
+
110
+ # RAS+ (x->Right, y->Anterior, z->Superior) to the radiological axial layout
111
+ # the checkpoint was trained on: rows anterior->posterior, columns
112
+ # right->left, slices inferior->superior (PrepareVolume then flips depth).
113
+ vol = np.ascontiguousarray(arr.transpose(1, 0, 2)[::-1, ::-1, :])
114
+ resolution = (zooms[1], zooms[0], zooms[2]) # (row, col, slice) mm
115
+ info = {
116
+ "shape": tuple(int(s) for s in arr.shape),
117
+ "spacing": tuple(round(z, 3) for z in zooms),
118
+ "hu_range": (float(np.percentile(vol, 0.5)), float(np.percentile(vol, 99.5))),
119
+ "z_coverage_mm": round(arr.shape[2] * zooms[2], 1),
120
+ }
121
+ return vol, resolution, info
122
+
123
+
124
+ def _auto_window(vol: np.ndarray) -> str:
125
+ """Pick a sensible display window: lung if there is lung parenchyma, else abdomen."""
126
+ lung_frac = float(np.mean((vol > -900.0) & (vol < -500.0)))
127
+ return "lung" if lung_frac > 0.06 else "abdomen"
128
+
129
+
130
+ def _u8(plane: np.ndarray) -> np.ndarray:
131
+ img = (np.clip(plane, 0.0, 1.0) * 255.0).astype(np.uint8)
132
+ return np.repeat(np.repeat(img, 2, axis=0), 2, axis=1) # 192 -> 384 px
133
+
134
+
135
+ def _preview_tiles(image: torch.Tensor, window: str) -> list:
136
+ """Orthogonal previews of the exact 192**3 cube the model sees."""
137
+ vol = image[CT_WINDOWS.index(window)].float().numpy()
138
+ depth, height, width = vol.shape
139
+ tiles = []
140
+ for frac in (0.3, 0.5, 0.7):
141
+ idx = int(round(frac * (depth - 1)))
142
+ tiles.append((_u8(vol[idx]), f"axial · slice {idx}/{depth - 1}"))
143
+ tiles.append((_u8(vol[:, height // 2, :]), "coronal · mid"))
144
+ tiles.append((_u8(vol[:, :, width // 2]), "sagittal · mid"))
145
+ return tiles
146
+
147
+
148
+ def _prep(path: str, preview_window: str):
149
+ """Load + preprocess a CT and render previews. CPU only."""
150
+ vol, resolution, info = _load_ct(path)
151
+ window = _auto_window(vol) if preview_window == "auto" else preview_window
152
+ image = PRE(vol, resolution=resolution) # (11, 192, 192, 192) float32
153
+ return image, _preview_tiles(image, window), info, window
154
+
155
+
156
+ def _volume_summary(info: dict, window: str, extra: str = "") -> str:
157
+ sx, sy, sz = info["spacing"]
158
+ lo, hi = info["hu_range"]
159
+ return (
160
+ f"**Volume** {info['shape'][0]}×{info['shape'][1]}×{info['shape'][2]} @ "
161
+ f"{sx}×{sy}×{sz} mm · {info['z_coverage_mm']} mm cranio-caudal coverage · "
162
+ f"HU p0.5–p99.5 {lo:.0f} → {hi:.0f} \n"
163
+ f"**Model input** 11×192×192×192 (1.5 mm isotropic, centre crop) · preview window `{window}`"
164
+ + (f" \n{extra}" if extra else "")
165
+ )
166
 
167
 
168
+ def _parse_lines(text: str, limit: int) -> list:
169
+ lines = [ln.strip() for ln in (text or "").splitlines()]
170
+ return [ln for ln in lines if ln][:limit]
 
 
171
 
172
 
173
  # ----------------------------------------------------------------------------
174
+ # Inference
175
  # ----------------------------------------------------------------------------
176
+ @spaces.GPU(duration=90)
177
+ def analyze(
178
+ ct_file: str,
179
+ volume_prompts: str = DEFAULT_VOLUME_PROMPTS,
180
+ organ_prompts: str = DEFAULT_ORGAN_PROMPTS,
181
+ organs: list = DEFAULT_ORGANS,
182
+ preview_window: str = "auto",
183
+ ):
184
+ """Zero-shot classify a CT volume against free-text findings with Jolia.
185
 
186
  Args:
187
+ ct_file: Path to a chest / abdominal CT volume in NIfTI format (.nii or .nii.gz).
188
+ volume_prompts: Whole-volume prompts, one per line (global CLIP head).
189
+ organ_prompts: Short findings phrases, one per line (per-organ CLIP head).
190
+ organs: Organ query slots to route the findings phrases to.
191
+ preview_window: CT display window for the preview images ("auto" picks lung or abdomen).
192
 
193
  Returns:
194
+ Orthogonal previews of the model input, a volume summary, the global
195
+ zero-shot table and the per-organ probability matrix.
196
  """
197
+ if not ct_file:
198
+ raise gr.Error("Upload a CT volume (NIfTI .nii / .nii.gz) first.")
199
+ vol_prompts = _parse_lines(volume_prompts, MAX_PROMPTS)
200
+ org_prompts = _parse_lines(organ_prompts, MAX_PROMPTS)
201
+ organs = [o for o in (organs or []) if o in ORGAN_NAMES][:MAX_ORGANS]
202
+ if not vol_prompts and not org_prompts:
203
+ raise gr.Error("Enter at least one prompt.")
204
 
205
  t0 = time.perf_counter()
206
+ image, tiles, info, window = _prep(ct_file, preview_window)
207
+ t_prep = time.perf_counter() - t0
208
 
209
+ t0 = time.perf_counter()
210
  with torch.no_grad():
211
+ x = image.unsqueeze(0).to("cuda")
212
+ cls, organ_queries = JOLIA.forward_with_queries(x) # (1, 576), (1, slots, 576)
213
+ image_emb = F.normalize(cls.float(), dim=-1, eps=1e-6)
214
+
215
+ global_rows = []
216
+ if vol_prompts:
217
+ text_features = TEXT(vol_prompts).to(image_emb.device) # (N, 4096)
218
+ text_emb = JOLIA.encode_text(text_features) # (N, 576)
219
+ cosine = (image_emb @ text_emb.t())[0]
220
+ logits = JOLIA.zero_shot_logits(image_emb, text_emb)[0]
221
+ probs = torch.sigmoid(logits)
222
+ global_rows = [
223
+ [p, round(float(lg), 4), round(float(pr), 4), round(float(cs), 4)]
224
+ for p, lg, pr, cs in zip(vol_prompts, logits, probs, cosine)
225
+ ]
226
+ global_rows.sort(key=lambda r: -r[1])
227
+
228
+ organ_rows = []
229
+ if org_prompts and organs:
230
+ organ_text = TEXT(org_prompts).to(image_emb.device)
231
+ organ_text_emb = JOLIA.encode_organ_text(organ_text) # (N, 576)
232
+ for name in organs:
233
+ idx = ORGAN_NAMES.index(name)
234
+ emb = F.normalize(organ_queries[:, idx, :].float(), dim=-1, eps=1e-6)
235
+ scale = JOLIA.organ_logit_scale[idx].float().exp()
236
+ bias = JOLIA.organ_text_bias[idx].float()
237
+ logits = (emb @ organ_text_emb.t())[0] * scale + bias
238
+ organ_rows.append([name] + [round(float(v), 4) for v in torch.sigmoid(logits)])
239
+ t_gpu = time.perf_counter() - t0
240
+
241
+ global_df = pd.DataFrame(
242
+ global_rows or [["—", 0.0, 0.0, 0.0]],
243
+ columns=["prompt", "calibrated logit", "match probability", "cosine"],
244
  )
245
+ organ_df = pd.DataFrame(
246
+ organ_rows or [["—"] + [0.0] * max(1, len(org_prompts))],
247
+ columns=["organ"] + (org_prompts or ["—"]),
248
+ )
249
+ best = f"**Top whole-volume match** · `{global_rows[0][0]}` (p={global_rows[0][2]:.3f}) \n" if global_rows else ""
250
+ summary = _volume_summary(
251
  info,
252
+ window,
253
+ f"{best}*preprocess {t_prep:.1f}s · encode + score {t_gpu:.1f}s*",
254
  )
255
+ return tiles, summary, global_df, organ_df
256
+
257
+
258
+ def preview(ct_file: str, preview_window: str = "auto"):
259
+ """Render orthogonal previews of the preprocessed CT volume (no GPU).
260
+
261
+ Args:
262
+ ct_file: Path to a CT volume in NIfTI format (.nii or .nii.gz).
263
+ preview_window: CT display window ("auto" picks lung or abdomen).
264
+
265
+ Returns:
266
+ Preview images of the 192**3 model input and a short volume summary.
267
+ """
268
+ if not ct_file:
269
+ return [], "Upload a CT volume (NIfTI `.nii` / `.nii.gz`) to get started."
270
+ _, tiles, info, window = _prep(ct_file, preview_window)
271
+ return tiles, _volume_summary(info, window, "*Preview only — press **Analyze** to score prompts.*")
272
 
273
 
274
  # ----------------------------------------------------------------------------
275
  # UI
276
  # ----------------------------------------------------------------------------
277
  CSS = """
278
+ #col-container { max-width: 1250px; margin: 0 auto; }
279
  .dark .gradio-container { color: var(--body-text-color); }
280
  """
281
 
282
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Jolia — zero-shot CT") as demo:
283
  with gr.Column(elem_id="col-container"):
284
  gr.Markdown(
285
+ "# Jolia — zero-shot CT analysis\n"
286
+ "[`raidium/Jolia`](https://huggingface.co/raidium/Jolia) is a 3D CT foundation model: it "
287
+ "encodes a whole chest / abdominal CT into one global embedding **and** 102 named "
288
+ "organ-query embeddings, both aligned with report text. Score any free-text finding "
289
+ "against the whole volume, or route it to a single organ.\n\n"
290
+ "⚠️ Research preview **not a medical device, not for clinical use.**"
 
 
 
 
 
291
  )
 
292
  with gr.Row():
293
+ with gr.Column(scale=4):
294
+ ct_file = gr.File(
295
+ label="CT volume (NIfTI .nii / .nii.gz)",
296
  file_types=[".nii", ".gz"],
297
  type="filepath",
298
  )
299
+ volume_prompts = gr.Textbox(
300
+ label="Whole-volume prompts (one per line)",
301
+ info="Scored against the global CLIP head — full sentences work best.",
302
+ value=DEFAULT_VOLUME_PROMPTS,
303
+ lines=5,
304
  )
305
+ organ_prompts = gr.Textbox(
306
+ label="Per-organ findings phrases (one per line)",
307
+ info="Scored against the per-organ head — short phrases work best.",
308
+ value=DEFAULT_ORGAN_PROMPTS,
309
+ lines=4,
 
310
  )
311
+ organs = gr.Dropdown(
312
+ label="Organ query slots",
313
+ choices=ORGAN_NAMES,
314
+ value=DEFAULT_ORGANS,
315
+ multiselect=True,
316
+ max_choices=MAX_ORGANS,
317
+ )
318
+ run = gr.Button("Analyze", variant="primary")
319
+ with gr.Accordion("Advanced", open=False):
320
+ preview_window = gr.Dropdown(
321
+ label="Preview window",
322
+ choices=PREVIEW_WINDOWS,
323
+ value="auto",
324
+ info="Display only — the model always sees all 11 windowing channels.",
325
  )
326
+ with gr.Column(scale=6):
327
+ gallery = gr.Gallery(
328
+ label="Model input (1.5 mm isotropic, 192³ centre crop)",
329
+ columns=3,
330
+ height=340,
331
+ object_fit="contain",
332
+ )
333
+ summary = gr.Markdown("Upload a CT volume (NIfTI `.nii` / `.nii.gz`) to get started.")
334
+ global_df = gr.Dataframe(
335
+ label="Whole-volume zero-shot (global CLIP head)",
336
+ headers=["prompt", "calibrated logit", "match probability", "cosine"],
337
+ wrap=True,
338
+ )
339
+ organ_df = gr.Dataframe(
340
+ label="Per-organ zero-shot — match probability per (organ, phrase)",
341
+ wrap=True,
342
+ )
343
 
344
+ gr.Markdown(
345
+ "### Examples\n"
346
+ "Public CT volumes from the [TotalSegmentator dataset](https://zenodo.org/records/10047292) "
347
+ "(Wasserthal et al., CC-BY-4.0), via "
348
+ "[`YongchengYAO/TotalSegmentator-CT-Lite`](https://huggingface.co/datasets/YongchengYAO/TotalSegmentator-CT-Lite). "
349
+ "Radiology labels in the file names come from that dataset's metadata."
 
 
 
350
  )
 
351
  gr.Examples(
352
  examples=[
353
+ [
354
+ "examples/chest_ct_lung_tumor_s1173.nii.gz",
355
+ "\n".join(
356
+ [
357
+ "a normal chest CT",
358
+ "a chest CT showing a pulmonary nodule",
359
+ "a chest CT showing pneumonia",
360
+ "a chest CT showing pleural effusion",
361
+ "a chest CT showing emphysema",
362
+ ]
363
+ ),
364
+ "\n".join(["looks normal", "a nodule", "a mass", "an effusion"]),
365
+ ],
366
+ [
367
+ "examples/chest_ct_inflammation_s1353.nii.gz",
368
+ "\n".join(
369
+ [
370
+ "a normal chest CT",
371
+ "a chest CT showing pneumonia",
372
+ "a chest CT showing consolidation",
373
+ "a chest CT showing a pulmonary nodule",
374
+ ]
375
+ ),
376
+ "\n".join(["looks normal", "consolidation", "an infection", "a nodule"]),
377
+ ],
378
+ [
379
+ "examples/abdomen_pelvis_ct_normal_s0143.nii.gz",
380
+ "\n".join(
381
+ [
382
+ "a normal abdominal CT",
383
+ "an abdominal CT showing a liver lesion",
384
+ "an abdominal CT showing hepatic steatosis",
385
+ "an abdominal CT showing bowel obstruction",
386
+ ]
387
+ ),
388
+ "\n".join(["looks normal", "a lesion", "an enlarged organ"]),
389
+ ],
390
+ [
391
+ "examples/abdomen_ct_tumor_s0168.nii.gz",
392
+ "\n".join(
393
+ [
394
+ "a normal abdominal CT",
395
+ "an abdominal CT showing a tumour",
396
+ "an abdominal CT showing a liver lesion",
397
+ "an abdominal CT showing enlarged lymph nodes",
398
+ ]
399
+ ),
400
+ "\n".join(["looks normal", "a lesion", "a mass", "an enlarged organ"]),
401
+ ],
402
  ],
403
+ inputs=[ct_file, volume_prompts, organ_prompts],
404
+ outputs=[gallery, summary, global_df, organ_df],
405
+ fn=analyze,
406
  cache_examples=True,
407
  cache_mode="lazy",
408
+ label=f"Example CT volumes ({CACHE_VERSION})",
409
  )
410
 
411
  gr.Markdown(
412
+ "Whole-volume scores use Jolia's global CLIP head; per-organ scores route the phrase to "
413
+ "one organ query through the ParallelOrganCLIP head (each organ has its own trained "
414
+ "temperature and bias). Probabilities are `sigmoid(calibrated logit)` — a per-pair "
415
+ '"is this a match?" score, not a softmax over prompts, so they do not sum to 1. '
416
+ "Text is encoded with the paired [`Qwen/Qwen3-Embedding-8B`](https://huggingface.co/Qwen/Qwen3-Embedding-8B) "
417
+ "(last-token pooling, context length 512). DICOM series can be converted with `dcm2niix`."
418
  )
419
 
420
+ ct_file.change(preview, inputs=[ct_file, preview_window], outputs=[gallery, summary])
421
+ preview_window.change(preview, inputs=[ct_file, preview_window], outputs=[gallery, summary])
422
  run.click(
423
+ analyze,
424
+ inputs=[ct_file, volume_prompts, organ_prompts, organs, preview_window],
425
+ outputs=[gallery, summary, global_df, organ_df],
426
+ api_name="analyze",
427
  )
428
 
429
  if __name__ == "__main__":
430
+ demo.launch(mcp_server=True)
examples/abdomen_ct_tumor_s0168.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f7fe41adbbffea3284c6675222bf1d0352efbaee3367574e428ad810c0aa747b
3
+ size 16649551
examples/abdomen_pelvis_ct_normal_s0143.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b61aefa67756e2ff763e5509ab318af93060b18a61c0e63e165449aa6880e83d
3
+ size 15146822
examples/chest_ct_inflammation_s1353.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cb23dd1759048ce8992efc636e2dae767987e703f941dfa1f214bb5f3b34bc5e
3
+ size 15963600
examples/chest_ct_lung_tumor_s1173.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7955aa6e0b065c404842be81815d6dbf5c0169a9c31da4514102ceda638ef787
3
+ size 18445659
requirements.txt CHANGED
@@ -1,7 +1,10 @@
1
- transformers
 
 
2
  timm
3
  einops
4
- numpy
5
  safetensors
6
- nibabel
7
  accelerate
 
 
 
 
1
+ torch
2
+ torchvision==0.26.0
3
+ transformers>=4.57.0
4
  timm
5
  einops
 
6
  safetensors
 
7
  accelerate
8
+ numpy
9
+ pandas
10
+ nibabel