Matanech commited on
Commit
1543fa3
Β·
verified Β·
1 Parent(s): fd1ad57

Upload 3 files

Browse files
Assignment_3_MIMIC_CXR_Recommender_v4.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
app.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chest X-ray Recommender - HuggingFace Space entry point.
3
+
4
+ Loads pre-computed CLIP embeddings (embeddings.parquet, built by the companion
5
+ notebook) and serves a Gradio UI that returns 3-5 visually similar X-rays for
6
+ a given text or image query.
7
+
8
+ Educational demo only. NOT a medical device.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import io
14
+ import os
15
+
16
+ import gradio as gr
17
+ import numpy as np
18
+ import pandas as pd
19
+ import torch
20
+ from PIL import Image
21
+ from transformers import CLIPModel, CLIPProcessor
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Config
25
+ # ---------------------------------------------------------------------------
26
+ MODEL_ID = os.environ.get("CLIP_MODEL_ID", "openai/clip-vit-base-patch32")
27
+ EMBEDDINGS_FILE = os.environ.get("EMBEDDINGS_FILE", "embeddings.parquet")
28
+ K_MIN = int(os.environ.get("K_MIN", "3"))
29
+ K_MAX = int(os.environ.get("K_MAX", "5"))
30
+ GAP_THRESHOLD = float(os.environ.get("GAP_THRESHOLD", "0.02"))
31
+
32
+ # Optional walk-through video (set the env var on your Space)
33
+ VIDEO_EMBED_ID = os.environ.get("VIDEO_EMBED_ID", "")
34
+
35
+ device = "cuda" if torch.cuda.is_available() else "cpu"
36
+ print(f"[startup] device = {device}")
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Load model
40
+ # ---------------------------------------------------------------------------
41
+ print(f"[startup] loading CLIP model: {MODEL_ID}")
42
+ clip_model = CLIPModel.from_pretrained(MODEL_ID).to(device).eval()
43
+ clip_processor = CLIPProcessor.from_pretrained(MODEL_ID)
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Load catalog (embeddings + thumbnails + reports)
47
+ # ---------------------------------------------------------------------------
48
+ print(f"[startup] loading catalog: {EMBEDDINGS_FILE}")
49
+ df = pd.read_parquet(EMBEDDINGS_FILE)
50
+ print(f"[startup] catalog rows: {len(df):,}")
51
+
52
+ EMB_MATRIX = np.vstack(df["embedding"].values).astype("float32")
53
+ # Defensive re-normalisation (cheap, idempotent)
54
+ norms = np.linalg.norm(EMB_MATRIX, axis=1, keepdims=True)
55
+ EMB_MATRIX = EMB_MATRIX / np.where(norms == 0, 1, norms)
56
+
57
+ def _b64_to_array(b64: str) -> np.ndarray:
58
+ """Decode a base64 JPEG thumbnail to a numpy RGB array (most reliable in Gradio)."""
59
+ img = Image.open(io.BytesIO(base64.b64decode(b64)))
60
+ img.load()
61
+ return np.array(img.convert("RGB"))
62
+
63
+ THUMB_ARRAYS = [_b64_to_array(b) for b in df["image_b64"]]
64
+ REPORTS = df["report"].fillna("").tolist()
65
+ CLUSTER = df["cluster"].astype(int).tolist() if "cluster" in df.columns else [0] * len(df)
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # CLIP encoders (version-stable: bypass get_image_features quirks)
70
+ # ---------------------------------------------------------------------------
71
+ def _to_tensor(out):
72
+ if torch.is_tensor(out):
73
+ return out
74
+ if hasattr(out, "image_embeds"):
75
+ return out.image_embeds
76
+ if hasattr(out, "text_embeds"):
77
+ return out.text_embeds
78
+ if hasattr(out, "pooler_output"):
79
+ return out.pooler_output
80
+ if hasattr(out, "last_hidden_state"):
81
+ return out.last_hidden_state[:, 0]
82
+ raise TypeError(f"Cannot unwrap CLIP output of type {type(out)}")
83
+
84
+
85
+ @torch.no_grad()
86
+ def _embed_image(pil_img: Image.Image) -> np.ndarray:
87
+ inputs = clip_processor(images=pil_img.convert("RGB"), return_tensors="pt").to(device)
88
+ vision_out = clip_model.vision_model(pixel_values=inputs["pixel_values"])
89
+ pooled = _to_tensor(vision_out)
90
+ emb = clip_model.visual_projection(pooled)
91
+ emb = emb / emb.norm(p=2, dim=-1, keepdim=True)
92
+ return emb.cpu().numpy()[0]
93
+
94
+
95
+ @torch.no_grad()
96
+ def _embed_text(text: str) -> np.ndarray:
97
+ inputs = clip_processor(
98
+ text=[text], return_tensors="pt",
99
+ padding=True, truncation=True, max_length=77,
100
+ ).to(device)
101
+ text_out = clip_model.text_model(
102
+ input_ids=inputs["input_ids"],
103
+ attention_mask=inputs.get("attention_mask"),
104
+ )
105
+ pooled = _to_tensor(text_out)
106
+ emb = clip_model.text_projection(pooled)
107
+ emb = emb / emb.norm(p=2, dim=-1, keepdim=True)
108
+ return emb.cpu().numpy()[0]
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Adaptive top-K
113
+ # ---------------------------------------------------------------------------
114
+ def _top_k(query_vec: np.ndarray, k_min: int = K_MIN, k_max: int = K_MAX,
115
+ gap_threshold: float = GAP_THRESHOLD):
116
+ """Return 3-5 results: top-3 baseline, expand if consecutive scores are close."""
117
+ scores = EMB_MATRIX @ query_vec.astype("float32")
118
+ order = np.argsort(-scores)
119
+
120
+ selected = [int(i) for i in order[:k_min]]
121
+ for i in range(k_min, min(k_max, len(order))):
122
+ prev_score = scores[order[i - 1]]
123
+ cand_score = scores[order[i]]
124
+ if (prev_score - cand_score) <= gap_threshold:
125
+ selected.append(int(order[i]))
126
+ else:
127
+ break
128
+
129
+ return [
130
+ {
131
+ "index" : i,
132
+ "score" : float(scores[i]),
133
+ "image" : THUMB_ARRAYS[i],
134
+ "report" : REPORTS[i],
135
+ "cluster" : CLUSTER[i],
136
+ }
137
+ for i in selected
138
+ ]
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Gradio handler
143
+ # ---------------------------------------------------------------------------
144
+ def recommend(text_query: str, image_query):
145
+ if image_query is not None:
146
+ q = _embed_image(image_query)
147
+ used = "uploaded image"
148
+ elif text_query and text_query.strip():
149
+ q = _embed_text(text_query.strip())
150
+ used = f'text query: "{text_query.strip()}"'
151
+ else:
152
+ return [], ("### ⚠️ No input provided\n\n"
153
+ "Please **upload a chest X-ray** or **type a description** "
154
+ "in the box on the left.")
155
+
156
+ results = _top_k(q)
157
+ gallery = [
158
+ (r["image"], f"Match #{n+1} (catalog #{r['index']}) - score {r['score']:.3f}")
159
+ for n, r in enumerate(results)
160
+ ]
161
+
162
+ header = f"_Query: **{used}** - showing **{len(results)}** matches"
163
+ if len(results) > 3:
164
+ header += " (extras included because scores are very close)_"
165
+ else:
166
+ header += "_"
167
+
168
+ details = header + "\n\n" + "\n\n".join(
169
+ f"#### Match {n+1} - similarity {r['score']:.3f} (cluster {r['cluster']})\n\n"
170
+ f"```\n{r['report'][:600]}{'...' if len(r['report']) > 600 else ''}\n```"
171
+ for n, r in enumerate(results)
172
+ )
173
+ return gallery, details
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # UI
178
+ # ---------------------------------------------------------------------------
179
+ CUSTOM_CSS = """
180
+ .gradio-container { max-width: 1200px !important; margin: 0 auto !important; }
181
+ .main-title {
182
+ text-align: center;
183
+ background: linear-gradient(135deg, #4a90e2 0%, #5e72e4 100%);
184
+ color: white;
185
+ padding: 30px 20px;
186
+ border-radius: 16px;
187
+ margin-bottom: 24px;
188
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
189
+ }
190
+ .main-title h1 { margin: 0; font-size: 2.2em; font-weight: 700; }
191
+ .main-title p { margin: 8px 0 0 0; opacity: 0.95; font-size: 1.1em; }
192
+ .info-card {
193
+ background: #f8f9fc;
194
+ border-left: 4px solid #4a90e2;
195
+ padding: 16px 20px;
196
+ border-radius: 8px;
197
+ margin: 16px 0;
198
+ }
199
+ .disclaimer-card {
200
+ background: #fff7e6;
201
+ border-left: 4px solid #ff9800;
202
+ padding: 12px 16px;
203
+ border-radius: 8px;
204
+ margin: 16px 0;
205
+ font-size: 0.95em;
206
+ }
207
+ .section-divider {
208
+ border: none;
209
+ height: 1px;
210
+ background: linear-gradient(90deg, transparent, #d0d7de, transparent);
211
+ margin: 24px 0;
212
+ }
213
+ """
214
+
215
+ with gr.Blocks(css=CUSTOM_CSS, theme=gr.themes.Soft(primary_hue="blue"),
216
+ title="Chest X-ray Recommender") as demo:
217
+
218
+ gr.HTML("""
219
+ <div class="main-title">
220
+ <h1>🩻 Chest X-ray Recommender</h1>
221
+ <p>AI-powered visual search across radiology studies</p>
222
+ </div>
223
+ """)
224
+
225
+ gr.HTML("""
226
+ <div class="info-card">
227
+ <h3 style="margin-top:0;">πŸ“– About this app</h3>
228
+ <p>This tool helps you find chest X-rays that look similar to your query.
229
+ The catalog draws on <b>MIMIC-CXR</b>, a real dataset of 30,000+ chest X-rays
230
+ paired with radiology reports. Each query β€” whether an uploaded image or a
231
+ text description β€” is encoded with <b>CLIP</b> (a multimodal AI model) and
232
+ compared against pre-computed embeddings using cosine similarity.</p>
233
+ <p><b>Use cases:</b> medical education, comparative case lookup, exploring
234
+ how visual AI represents medical imagery.</p>
235
+ </div>
236
+ """)
237
+
238
+ gr.HTML("""
239
+ <div class="disclaimer-card">
240
+ ⚠️ <b>Educational demo only.</b> This is not a medical device and must not be
241
+ used for clinical decisions. The recommendations reflect visual similarity in
242
+ a general-purpose AI model β€” not medical diagnosis.
243
+ </div>
244
+ """)
245
+
246
+ gr.Markdown("## πŸ” How to use this app")
247
+ gr.Markdown("""
248
+ You have **two ways** to query the system:
249
+
250
+ **πŸ–ΌοΈ Option A β€” Upload an X-ray image:** Drag and drop or click the image upload
251
+ area on the left to provide a chest X-ray. The app will encode your image and
252
+ find visually similar studies.
253
+
254
+ **πŸ“ Option B β€” Describe a finding in English:** Type a clinical description in
255
+ the text box (e.g. *"right lower lobe pneumonia"*, *"pneumothorax"*,
256
+ *"clear lungs"*). The app uses CLIP's text encoder so words map to the same
257
+ vector space as the images.
258
+
259
+ Then click **Find Similar X-rays**. The app returns the **3 closest matches**,
260
+ plus up to **2 extra results** (5 total) when the scores are tightly clustered β€”
261
+ giving you "second opinions" when the model is uncertain.
262
+ """)
263
+
264
+ gr.HTML('<hr class="section-divider">')
265
+
266
+ with gr.Row(equal_height=False):
267
+ with gr.Column(scale=1):
268
+ gr.Markdown("### πŸ“₯ Your query")
269
+ text_in = gr.Textbox(
270
+ lines=3,
271
+ label="πŸ“ Describe a finding",
272
+ placeholder='e.g. "bilateral pleural effusion with cardiomegaly"',
273
+ )
274
+ image_in = gr.Image(
275
+ type="pil",
276
+ label="πŸ–ΌοΈ Upload a chest X-ray here (PNG / JPG)",
277
+ height=300,
278
+ )
279
+ btn = gr.Button("πŸ” Find Similar X-rays", variant="primary", size="lg")
280
+ gr.Examples(
281
+ examples=[
282
+ ["bilateral pleural effusion with cardiomegaly", None],
283
+ ["clear lungs, no acute cardiopulmonary process", None],
284
+ ["right lower lobe pneumonia", None],
285
+ ["pneumothorax", None],
286
+ ["pulmonary edema with vascular congestion", None],
287
+ ["enlarged cardiac silhouette", None],
288
+ ],
289
+ inputs=[text_in, image_in],
290
+ label="πŸ’‘ Click an example to try it",
291
+ )
292
+
293
+ with gr.Column(scale=2):
294
+ gr.Markdown("### πŸ“€ Recommended X-rays")
295
+ gallery = gr.Gallery(
296
+ label="Top matches (most similar first)",
297
+ columns=3,
298
+ height=380,
299
+ object_fit="contain",
300
+ show_label=True,
301
+ )
302
+ gr.Markdown("### πŸ“‹ Radiology reports for the matches")
303
+ details = gr.Markdown()
304
+
305
+ btn.click(recommend, inputs=[text_in, image_in], outputs=[gallery, details])
306
+
307
+ gr.HTML('<hr class="section-divider">')
308
+ gr.Markdown(f"""
309
+ ### πŸ”¬ Under the hood
310
+
311
+ - **Model:** `{MODEL_ID}` (CLIP ViT-B/32, 512-dim embeddings)
312
+ - **Catalog:** {len(df):,} X-rays from `MLforHealthcare/mimic-cxr`
313
+ - **Similarity:** Cosine similarity (via dot product of L2-normalized vectors)
314
+ - **Adaptive top-K:** {K_MIN} baseline matches, expands to {K_MAX} if score gaps ≀ {GAP_THRESHOLD}
315
+ """)
316
+
317
+ if VIDEO_EMBED_ID:
318
+ gr.HTML(f"""
319
+ <hr class="section-divider">
320
+ <h3 style="text-align:center;">🎬 Walk-through video</h3>
321
+ <div style="display:flex; justify-content:center;">
322
+ <iframe width="720" height="405"
323
+ src="https://www.youtube.com/embed/{VIDEO_EMBED_ID}"
324
+ title="Assignment walk-through" frameborder="0"
325
+ allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen>
326
+ </iframe>
327
+ </div>
328
+ """)
329
+
330
+ if __name__ == "__main__":
331
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio>=5.0.0
2
+ transformers>=4.45.0
3
+ torch>=2.2.0
4
+ Pillow>=10.4.0
5
+ numpy>=1.26.0
6
+ pandas>=2.2.0
7
+ pyarrow>=17.0.0