Spaces:
Sleeping
Sleeping
Upload 3 files
Browse files- Assignment_3_MIMIC_CXR_Recommender_v4.ipynb +0 -0
- app.py +331 -0
- requirements.txt +7 -0
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
|