convitom commited on
Commit ·
3671ffb
1
Parent(s): d4a6e08
- demo/__init__.py +0 -0
- demo/app.py +204 -0
- demo/cxr_demo.py +261 -0
- model/a.py +4 -0
- scripts/cxrvlm_demo.ipynb +154 -0
demo/__init__.py
ADDED
|
File without changes
|
demo/app.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py
|
| 3 |
+
------
|
| 4 |
+
Gradio chatbot demo for the trained CXR-VLM.
|
| 5 |
+
|
| 6 |
+
Flow
|
| 7 |
+
----
|
| 8 |
+
1. Upload a chest X-ray image.
|
| 9 |
+
2. The model auto-generates a report — Findings + Impression (cascade,
|
| 10 |
+
matching report_mode=split_cascade) — as the assistant's first message.
|
| 11 |
+
3. Ask any follow-up question about the same image; it's answered VQA-style,
|
| 12 |
+
the way the model was trained.
|
| 13 |
+
|
| 14 |
+
The image stays "loaded" for the whole conversation; uploading a new image
|
| 15 |
+
resets the chat and produces a fresh report.
|
| 16 |
+
|
| 17 |
+
Usage (Colab / cloud GPU — auto-download the checkpoint from the HF runs repo)
|
| 18 |
+
-----------------------------------------------------------------------------
|
| 19 |
+
python -m demo.app \
|
| 20 |
+
--hf_repo hieu3636/cxr-vlm-runs \
|
| 21 |
+
--run_id MIMIC-CXR_resized_run_3 \
|
| 22 |
+
--share # public Gradio link (needed on Colab)
|
| 23 |
+
|
| 24 |
+
Usage (local — checkpoint already on disk)
|
| 25 |
+
------------------------------------------
|
| 26 |
+
python -m demo.app \
|
| 27 |
+
--checkpoint checkpoints/IU-Xray_run_1/stage2_instruct # dir OR .pt
|
| 28 |
+
|
| 29 |
+
Notes
|
| 30 |
+
-----
|
| 31 |
+
* On Colab pass --share so you get a public URL (the in-VM port isn't
|
| 32 |
+
reachable otherwise).
|
| 33 |
+
* --hf_token is optional if the runs repo is public or $HF_TOKEN is set.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
import argparse
|
| 37 |
+
import sys
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
|
| 40 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 41 |
+
|
| 42 |
+
from demo.cxr_demo import CXRChatbot, download_checkpoint_from_hf
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
WELCOME = (
|
| 46 |
+
"Upload a chest X-ray to begin. CXR-HIEU writes a radiology report for it, "
|
| 47 |
+
"then you can ask any follow-up question about the same image."
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def parse_args():
|
| 52 |
+
p = argparse.ArgumentParser(description="CXR-VLM chatbot demo (Gradio)")
|
| 53 |
+
# Checkpoint source — either a local path OR an HF runs-repo coordinate.
|
| 54 |
+
p.add_argument("--checkpoint", type=str, default=None,
|
| 55 |
+
help="Local checkpoint dir (contains checkpoint_projection.pt "
|
| 56 |
+
"+ checkpoint_lora/) or a stage2_final.pt path. "
|
| 57 |
+
"If omitted, --hf_repo + --run_id are used to download.")
|
| 58 |
+
p.add_argument("--hf_repo", type=str, default="hieu3636/cxr-vlm-runs",
|
| 59 |
+
help="HF runs repo to pull the checkpoint from.")
|
| 60 |
+
p.add_argument("--run_id", type=str, default="MIMIC-CXR_resized_run_3",
|
| 61 |
+
help="Run id on the HF runs repo, e.g. MIMIC-CXR_resized_run_3.")
|
| 62 |
+
p.add_argument("--stage", type=str, default="stage2",
|
| 63 |
+
choices=["stage2", "stage1"],
|
| 64 |
+
help="Which stage's checkpoint to load (default stage2).")
|
| 65 |
+
p.add_argument("--which", type=str, default="best",
|
| 66 |
+
choices=["best", "last"],
|
| 67 |
+
help="best (lowest eval_loss) or last (default best).")
|
| 68 |
+
p.add_argument("--hf_token", type=str, default=None,
|
| 69 |
+
help="HF token (defaults to $HF_TOKEN). Needed for private repos.")
|
| 70 |
+
p.add_argument("--model_config", type=str, default="configs/model_config.yaml")
|
| 71 |
+
p.add_argument("--device", type=str, default="auto",
|
| 72 |
+
choices=["auto", "cuda", "cpu"])
|
| 73 |
+
p.add_argument("--max_new_tokens", type=int, default=300)
|
| 74 |
+
p.add_argument("--share", action="store_true",
|
| 75 |
+
help="Create a public Gradio link (use this on Colab).")
|
| 76 |
+
p.add_argument("--server_name", type=str, default="0.0.0.0")
|
| 77 |
+
p.add_argument("--server_port", type=int, default=7860)
|
| 78 |
+
return p.parse_args()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def resolve_checkpoint(args) -> str:
|
| 82 |
+
if args.checkpoint:
|
| 83 |
+
return args.checkpoint
|
| 84 |
+
if not args.run_id:
|
| 85 |
+
raise SystemExit(
|
| 86 |
+
"Provide --checkpoint <path>, OR --run_id <id> (with --hf_repo) "
|
| 87 |
+
"to auto-download from the HF runs repo."
|
| 88 |
+
)
|
| 89 |
+
print(f"[app] downloading {args.run_id}/{args.stage}/{args.which} "
|
| 90 |
+
f"from {args.hf_repo} …")
|
| 91 |
+
return download_checkpoint_from_hf(
|
| 92 |
+
repo_id = args.hf_repo,
|
| 93 |
+
run_id = args.run_id,
|
| 94 |
+
stage = args.stage,
|
| 95 |
+
which = args.which,
|
| 96 |
+
token = args.hf_token,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def build_ui(bot: CXRChatbot, max_new_tokens: int):
|
| 101 |
+
import gradio as gr
|
| 102 |
+
|
| 103 |
+
with gr.Blocks(title="CXR-HIEU Demo", theme=gr.themes.Soft()) as demo:
|
| 104 |
+
gr.Markdown(
|
| 105 |
+
"# 🩻 CXR-HIEU — Chest X-ray Report & Q&A\n"
|
| 106 |
+
"Upload a chest X-ray. CXR-HIEU generates a radiology report and "
|
| 107 |
+
"answers questions about the image."
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Holds the active conversation's session dict {img, pnu}. The image
|
| 111 |
+
# (and its predicted PNU) persist across follow-up questions; a new
|
| 112 |
+
# upload replaces it → the bot answers about the new image.
|
| 113 |
+
sess_state = gr.State(value=None)
|
| 114 |
+
|
| 115 |
+
with gr.Row():
|
| 116 |
+
with gr.Column(scale=1):
|
| 117 |
+
image_in = gr.Image(type="pil", label="Chest X-ray image",
|
| 118 |
+
height=380)
|
| 119 |
+
analyze_btn = gr.Button("🔍 Analyze image", variant="primary")
|
| 120 |
+
gr.Markdown(
|
| 121 |
+
"_Upload an image and click **Analyze image** (it also "
|
| 122 |
+
"analyzes automatically on upload). Then ask questions on "
|
| 123 |
+
"the right._"
|
| 124 |
+
)
|
| 125 |
+
with gr.Column(scale=2):
|
| 126 |
+
chatbot = gr.Chatbot(label="Conversation", height=460,
|
| 127 |
+
type="messages")
|
| 128 |
+
with gr.Row():
|
| 129 |
+
msg = gr.Textbox(
|
| 130 |
+
placeholder="Ask about this image, e.g. Is there pleural effusion?",
|
| 131 |
+
scale=5, show_label=False, container=False,
|
| 132 |
+
)
|
| 133 |
+
send_btn = gr.Button("Send", scale=1, variant="primary")
|
| 134 |
+
clear_btn = gr.Button("🗑️ Clear conversation")
|
| 135 |
+
|
| 136 |
+
# ── handlers ───────────────────────────────────────────────────────
|
| 137 |
+
|
| 138 |
+
def on_analyze(image):
|
| 139 |
+
"""New image → new session, reset chat, generate the report."""
|
| 140 |
+
if image is None:
|
| 141 |
+
return None, [{
|
| 142 |
+
"role": "assistant",
|
| 143 |
+
"content": "⚠️ Please upload a chest X-ray first.",
|
| 144 |
+
}]
|
| 145 |
+
session = bot.prepare(image)
|
| 146 |
+
report = bot.generate_report(session, max_new_tokens=max_new_tokens)
|
| 147 |
+
history = [{"role": "assistant", "content": report}]
|
| 148 |
+
return session, history
|
| 149 |
+
|
| 150 |
+
def on_send(message, history, session):
|
| 151 |
+
history = history or []
|
| 152 |
+
if not message or not message.strip():
|
| 153 |
+
return "", history
|
| 154 |
+
if session is None:
|
| 155 |
+
history.append({"role": "user", "content": message})
|
| 156 |
+
history.append({
|
| 157 |
+
"role": "assistant",
|
| 158 |
+
"content": "⚠️ Please upload an image and click "
|
| 159 |
+
"**Analyze image** before asking questions.",
|
| 160 |
+
})
|
| 161 |
+
return "", history
|
| 162 |
+
answer = bot.answer_question(session, message.strip(),
|
| 163 |
+
max_new_tokens=max_new_tokens)
|
| 164 |
+
history.append({"role": "user", "content": message})
|
| 165 |
+
history.append({"role": "assistant", "content": answer})
|
| 166 |
+
return "", history
|
| 167 |
+
|
| 168 |
+
def on_clear():
|
| 169 |
+
return None, None, ""
|
| 170 |
+
|
| 171 |
+
# Auto-analyze on upload AND via the button.
|
| 172 |
+
image_in.upload(on_analyze, inputs=image_in,
|
| 173 |
+
outputs=[sess_state, chatbot])
|
| 174 |
+
analyze_btn.click(on_analyze, inputs=image_in,
|
| 175 |
+
outputs=[sess_state, chatbot])
|
| 176 |
+
send_btn.click(on_send, inputs=[msg, chatbot, sess_state],
|
| 177 |
+
outputs=[msg, chatbot])
|
| 178 |
+
msg.submit(on_send, inputs=[msg, chatbot, sess_state],
|
| 179 |
+
outputs=[msg, chatbot])
|
| 180 |
+
clear_btn.click(on_clear, outputs=[sess_state, chatbot, msg])
|
| 181 |
+
|
| 182 |
+
gr.Markdown(f"\n\n_{WELCOME}_")
|
| 183 |
+
|
| 184 |
+
return demo
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def main():
|
| 188 |
+
args = parse_args()
|
| 189 |
+
ckpt = resolve_checkpoint(args)
|
| 190 |
+
bot = CXRChatbot(
|
| 191 |
+
checkpoint_dir = ckpt,
|
| 192 |
+
model_config = args.model_config,
|
| 193 |
+
device = args.device,
|
| 194 |
+
)
|
| 195 |
+
demo = build_ui(bot, args.max_new_tokens)
|
| 196 |
+
demo.queue().launch(
|
| 197 |
+
share = args.share,
|
| 198 |
+
server_name = args.server_name,
|
| 199 |
+
server_port = args.server_port,
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
if __name__ == "__main__":
|
| 204 |
+
main()
|
demo/cxr_demo.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cxr_demo.py
|
| 3 |
+
-----------
|
| 4 |
+
Reusable inference core for the CXR-VLM chatbot demo.
|
| 5 |
+
|
| 6 |
+
Wraps the trained model into the operations the UI needs:
|
| 7 |
+
|
| 8 |
+
1. download_checkpoint_from_hf(...) — pull {run_id}/stage2/best/ from the HF
|
| 9 |
+
runs repo into a local dir that `load_checkpoint` understands.
|
| 10 |
+
2. CXRChatbot(...) — loads the model once, then:
|
| 11 |
+
.prepare(PIL.Image) → session dict {img, pnu} kept in UI state
|
| 12 |
+
.generate_report(session) → cascade findings → impression
|
| 13 |
+
.answer_question(session, q) → VQA-style free-form answer
|
| 14 |
+
|
| 15 |
+
PNU (CheXpert classifier) support
|
| 16 |
+
---------------------------------
|
| 17 |
+
Runs trained on MIMIC-CXR with the Stage-0 classifier ship a
|
| 18 |
+
`checkpoint_chexpert_classifier.pt` inside `stage2/best/`. When that file is
|
| 19 |
+
present we ENABLE the classifier and, per image, predict the PNU 3-section
|
| 20 |
+
string ("Positive/Negative/Uncertain Abnormalities: ...") and prepend it to the
|
| 21 |
+
findings + VQA prompts — exactly the abnormality-guidance the model saw in
|
| 22 |
+
training. IU-Xray runs have no such file → PNU is disabled automatically.
|
| 23 |
+
|
| 24 |
+
Cascade (report_mode=split_cascade) mirrors training:
|
| 25 |
+
• findings — prompt context = PNU (or none if no classifier)
|
| 26 |
+
• impression — prompt context = "Findings: <generated findings>" (NOT PNU)
|
| 27 |
+
• vqa — prompt context = PNU (or none)
|
| 28 |
+
|
| 29 |
+
So at inference we generate findings first, then feed them back as the
|
| 30 |
+
impression's context. No ground truth needed.
|
| 31 |
+
|
| 32 |
+
The model is single-turn: each answer is conditioned on (image + current
|
| 33 |
+
prompt) only — it does NOT carry prior chat turns as text. The UI keeps the
|
| 34 |
+
image (and its PNU) in session state so follow-up questions stay about the same
|
| 35 |
+
image, and a new upload starts a fresh session.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
import os
|
| 41 |
+
import sys
|
| 42 |
+
from pathlib import Path
|
| 43 |
+
from typing import Optional, Dict, Any
|
| 44 |
+
|
| 45 |
+
# Make the repo root importable when this file is run directly.
|
| 46 |
+
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 47 |
+
if str(_REPO_ROOT) not in sys.path:
|
| 48 |
+
sys.path.insert(0, str(_REPO_ROOT))
|
| 49 |
+
|
| 50 |
+
# Silence per-shard HF download tqdm spam before transformers is imported.
|
| 51 |
+
import utils._quiet # noqa: F401,E402
|
| 52 |
+
|
| 53 |
+
CLASSIFIER_FILE = "checkpoint_chexpert_classifier.pt"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 57 |
+
# Checkpoint download
|
| 58 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 59 |
+
|
| 60 |
+
def download_checkpoint_from_hf(
|
| 61 |
+
repo_id: str,
|
| 62 |
+
run_id: str,
|
| 63 |
+
stage: str = "stage2",
|
| 64 |
+
which: str = "best",
|
| 65 |
+
token: Optional[str] = None,
|
| 66 |
+
local_root: str = "checkpoints/_demo_ckpt",
|
| 67 |
+
) -> str:
|
| 68 |
+
"""
|
| 69 |
+
Download `{run_id}/{stage}/{which}/` from the HF *runs* repo and return the
|
| 70 |
+
local directory holding `checkpoint_projection.pt`, `checkpoint_lora/`, and
|
| 71 |
+
(for MIMIC runs) `checkpoint_chexpert_classifier.pt`.
|
| 72 |
+
|
| 73 |
+
The returned path is passed straight to `utils.checkpoint.load_checkpoint`,
|
| 74 |
+
which (given a directory) loads files prefixed with `checkpoint_` — the
|
| 75 |
+
exact names the training callbacks upload to `{stage}/best/`.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
repo_id: e.g. "hieu3636/cxr-vlm-runs"
|
| 79 |
+
run_id: e.g. "MIMIC-CXR_resized_run_3"
|
| 80 |
+
stage: "stage2" (instruction-tuned, default) or "stage1"
|
| 81 |
+
which: "best" (lowest eval_loss, default) or "last"
|
| 82 |
+
token: HF token; falls back to $HF_TOKEN.
|
| 83 |
+
local_root: where to mirror the repo subtree locally.
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
Absolute path to the local `{which}/` directory.
|
| 87 |
+
"""
|
| 88 |
+
from huggingface_hub import snapshot_download
|
| 89 |
+
|
| 90 |
+
token = token or os.environ.get("HF_TOKEN")
|
| 91 |
+
subpath = f"{run_id}/{stage}/{which}"
|
| 92 |
+
staging = Path(local_root)
|
| 93 |
+
staging.mkdir(parents=True, exist_ok=True)
|
| 94 |
+
|
| 95 |
+
snapshot_download(
|
| 96 |
+
repo_id = repo_id,
|
| 97 |
+
repo_type = "model",
|
| 98 |
+
token = token,
|
| 99 |
+
allow_patterns = [f"{subpath}/**"],
|
| 100 |
+
local_dir = str(staging),
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
ckpt_dir = staging / run_id / stage / which
|
| 104 |
+
if not ckpt_dir.is_dir():
|
| 105 |
+
raise FileNotFoundError(
|
| 106 |
+
f"Expected {subpath}/ in {repo_id} but nothing was downloaded to "
|
| 107 |
+
f"{ckpt_dir}. Check repo_id / run_id / stage / which."
|
| 108 |
+
)
|
| 109 |
+
proj = ckpt_dir / "checkpoint_projection.pt"
|
| 110 |
+
if not proj.is_file():
|
| 111 |
+
found = "\n ".join(p.name for p in ckpt_dir.iterdir())
|
| 112 |
+
raise FileNotFoundError(
|
| 113 |
+
f"{proj} not found. Files present in {ckpt_dir}:\n {found}\n"
|
| 114 |
+
f"(The loader needs checkpoint_projection.pt + checkpoint_lora/.)"
|
| 115 |
+
)
|
| 116 |
+
return str(ckpt_dir)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ──────────────────────────────���───────────────────────────────────────────
|
| 120 |
+
# Chatbot core
|
| 121 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 122 |
+
|
| 123 |
+
class CXRChatbot:
|
| 124 |
+
"""Loads the trained CXR-VLM once and serves report + VQA generation."""
|
| 125 |
+
|
| 126 |
+
def __init__(
|
| 127 |
+
self,
|
| 128 |
+
checkpoint_dir: str,
|
| 129 |
+
model_config: str = "configs/model_config.yaml",
|
| 130 |
+
device: str = "auto",
|
| 131 |
+
cpu_dtype: str = "float32",
|
| 132 |
+
use_pnu: str = "auto", # "auto" | True | False
|
| 133 |
+
):
|
| 134 |
+
import torch
|
| 135 |
+
from omegaconf import OmegaConf
|
| 136 |
+
from model import CXRVisionLanguageModel
|
| 137 |
+
from model.rad_dino import BioViLTEncoder
|
| 138 |
+
from utils.checkpoint import load_checkpoint
|
| 139 |
+
|
| 140 |
+
self.torch = torch
|
| 141 |
+
self.device = self._resolve_device(device)
|
| 142 |
+
|
| 143 |
+
# Enable the CheXpert (PNU) classifier iff the checkpoint ships one,
|
| 144 |
+
# unless the caller forces it on/off.
|
| 145 |
+
clf_file = Path(checkpoint_dir) / CLASSIFIER_FILE
|
| 146 |
+
if use_pnu == "auto":
|
| 147 |
+
self.use_pnu = clf_file.is_file()
|
| 148 |
+
else:
|
| 149 |
+
self.use_pnu = bool(use_pnu)
|
| 150 |
+
if self.use_pnu and not clf_file.is_file():
|
| 151 |
+
print(f"[CXRChatbot] WARNING: use_pnu forced on but {clf_file} "
|
| 152 |
+
"is missing — PNU prediction will use random weights.")
|
| 153 |
+
|
| 154 |
+
model_cfg = OmegaConf.load(model_config)
|
| 155 |
+
self._patch_cfg_for_device(model_cfg, self.device, cpu_dtype)
|
| 156 |
+
model_cfg.chexpert_classifier.enabled = self.use_pnu
|
| 157 |
+
|
| 158 |
+
print(f"[CXRChatbot] building model on device={self.device} "
|
| 159 |
+
f"(PNU/CheXpert guidance: {'ON' if self.use_pnu else 'OFF'}) …")
|
| 160 |
+
self.model = CXRVisionLanguageModel(model_cfg)
|
| 161 |
+
load_checkpoint(self.model, checkpoint_dir)
|
| 162 |
+
|
| 163 |
+
# device_map already placed the (quantized) LLM; move the rest.
|
| 164 |
+
self.model = self.model.to(self.device)
|
| 165 |
+
self.model.eval()
|
| 166 |
+
|
| 167 |
+
self.transform = BioViLTEncoder.get_transform("val")
|
| 168 |
+
print("[CXRChatbot] ready.")
|
| 169 |
+
|
| 170 |
+
# ── device helpers (mirror evaluation/inference.py) ────────────────────
|
| 171 |
+
|
| 172 |
+
def _resolve_device(self, choice: str) -> str:
|
| 173 |
+
import torch
|
| 174 |
+
if choice == "auto":
|
| 175 |
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
| 176 |
+
if choice == "cuda" and not torch.cuda.is_available():
|
| 177 |
+
raise SystemExit("device=cuda requested but no CUDA GPU is visible.")
|
| 178 |
+
return choice
|
| 179 |
+
|
| 180 |
+
def _patch_cfg_for_device(self, model_cfg, device: str, cpu_dtype: str):
|
| 181 |
+
if device == "cpu":
|
| 182 |
+
model_cfg.llm.load_in_4bit = False
|
| 183 |
+
model_cfg.llm.load_in_8bit = False
|
| 184 |
+
model_cfg.llm.torch_dtype = cpu_dtype
|
| 185 |
+
model_cfg.llm.device_map = None
|
| 186 |
+
print("[CXRChatbot] CPU mode: 4-bit/8-bit disabled "
|
| 187 |
+
f"(dtype={cpu_dtype}, expect very slow generation).")
|
| 188 |
+
|
| 189 |
+
# ── session setup ───────────────────────────────────────────────────────
|
| 190 |
+
|
| 191 |
+
def prepare(self, image) -> Dict[str, Any]:
|
| 192 |
+
"""
|
| 193 |
+
PIL.Image → session dict for the conversation:
|
| 194 |
+
{"img": (1,C,H,W) tensor on device, "pnu": str|None}
|
| 195 |
+
PNU is predicted once here and reused for every follow-up question, so
|
| 196 |
+
the (expensive) encoder pass that drives it runs a single time per image.
|
| 197 |
+
"""
|
| 198 |
+
img = image.convert("RGB")
|
| 199 |
+
img_t = self.transform(img).unsqueeze(0).to(self.device)
|
| 200 |
+
pnu = None
|
| 201 |
+
if self.use_pnu:
|
| 202 |
+
try:
|
| 203 |
+
pnu = self.model.predict_structured_findings(img_t)[0]
|
| 204 |
+
except Exception as e:
|
| 205 |
+
print(f"[CXRChatbot] PNU prediction failed ({type(e).__name__}: "
|
| 206 |
+
f"{e}); continuing without abnormality guidance.")
|
| 207 |
+
return {"img": img_t, "pnu": pnu}
|
| 208 |
+
|
| 209 |
+
# ── generation ─────────────────────────────────────────────────────────
|
| 210 |
+
|
| 211 |
+
def _generate(self, img_t, prompt: str, max_new_tokens: int) -> str:
|
| 212 |
+
out = self.model.generate(
|
| 213 |
+
images = img_t,
|
| 214 |
+
prompts = [prompt],
|
| 215 |
+
max_new_tokens = max_new_tokens,
|
| 216 |
+
temperature = 1e-5, # greedy; HF complains if exactly 0
|
| 217 |
+
do_sample = False,
|
| 218 |
+
num_beams = 1,
|
| 219 |
+
repetition_penalty = 1.2, # break the greedy template loop
|
| 220 |
+
no_repeat_ngram_size = 3,
|
| 221 |
+
)[0]
|
| 222 |
+
return out.strip()
|
| 223 |
+
|
| 224 |
+
def generate_findings(self, session, max_new_tokens: int = 300) -> str:
|
| 225 |
+
from data.prompt_templates import build_findings_prompt
|
| 226 |
+
prompt = build_findings_prompt(
|
| 227 |
+
structured_findings=session.get("pnu"), randomize=False)
|
| 228 |
+
return self._generate(session["img"], prompt, max_new_tokens)
|
| 229 |
+
|
| 230 |
+
def generate_impression(self, session, findings: str,
|
| 231 |
+
max_new_tokens: int = 200) -> str:
|
| 232 |
+
"""Impression conditioned on the (just-generated) findings text."""
|
| 233 |
+
from data.prompt_templates import build_impression_prompt
|
| 234 |
+
ctx = f"Findings: {findings.strip()}" if findings.strip() else None
|
| 235 |
+
prompt = build_impression_prompt(structured_findings=ctx, randomize=False)
|
| 236 |
+
return self._generate(session["img"], prompt, max_new_tokens)
|
| 237 |
+
|
| 238 |
+
def generate_report(self, session, max_new_tokens: int = 300) -> str:
|
| 239 |
+
"""
|
| 240 |
+
Cascade: findings → impression, returned as ONE flowing paragraph —
|
| 241 |
+
the findings sentences first, the impression sentences appended at the
|
| 242 |
+
end, with no "Findings:" / "Impression:" labels. Matches the
|
| 243 |
+
split_cascade training recipe (impression sees findings; findings/VQA
|
| 244 |
+
see the PNU abnormality guidance when available).
|
| 245 |
+
"""
|
| 246 |
+
findings = self.generate_findings(session, max_new_tokens).strip()
|
| 247 |
+
impression = self.generate_impression(session, findings).strip()
|
| 248 |
+
|
| 249 |
+
report = findings
|
| 250 |
+
if impression:
|
| 251 |
+
if report and not report.endswith((".", "!", "?")):
|
| 252 |
+
report += "."
|
| 253 |
+
report = (report + " " + impression).strip()
|
| 254 |
+
return report if report else "(no output)"
|
| 255 |
+
|
| 256 |
+
def answer_question(self, session, question: str,
|
| 257 |
+
max_new_tokens: int = 200) -> str:
|
| 258 |
+
"""Free-form / VQA answer about the image (as the model was trained)."""
|
| 259 |
+
from data.prompt_templates import build_vqa_prompt
|
| 260 |
+
prompt = build_vqa_prompt(question, structured_findings=session.get("pnu"))
|
| 261 |
+
return self._generate(session["img"], prompt, max_new_tokens)
|
model/a.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from rad_dino import RadDino
|
| 2 |
+
from rad_dino.utils import download_sample_image
|
| 3 |
+
model = RadDino.from_pretrained("rad-dino/rad-dino-base")
|
| 4 |
+
print(model)
|
scripts/cxrvlm_demo.ipynb
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "bc39a82e",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# CXR-HIEU — Demo Chatbot (Colab)\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"Upload a chest X-ray and CXR-HIEU writes a radiology report, then answers follow-up questions (VQA) about the image.\n",
|
| 11 |
+
"\n",
|
| 12 |
+
"**Before running:** `Runtime → Change runtime type → GPU` (T4/L4/A100 all work).\n",
|
| 13 |
+
"\n",
|
| 14 |
+
"Just set `RUN_ID` (and `HF_TOKEN` if the runs repo is private) in the config cell, then `Runtime → Run all`. Finally click the `*.gradio.live` link."
|
| 15 |
+
]
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"cell_type": "markdown",
|
| 19 |
+
"id": "98eae020",
|
| 20 |
+
"metadata": {},
|
| 21 |
+
"source": [
|
| 22 |
+
"## 1. Configuration"
|
| 23 |
+
]
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"cell_type": "code",
|
| 27 |
+
"execution_count": null,
|
| 28 |
+
"id": "21bc5b03",
|
| 29 |
+
"metadata": {},
|
| 30 |
+
"outputs": [],
|
| 31 |
+
"source": [
|
| 32 |
+
"import os\n",
|
| 33 |
+
"\n",
|
| 34 |
+
"# ── REQUIRED: id of the trained run on the HF runs repo ──\n",
|
| 35 |
+
"RUN_ID = \"MIMIC-CXR_resized_run_3\" # run that ships the full PNU CheXpert classifier\n",
|
| 36 |
+
"HF_RUNS = \"hieu3636/cxr-vlm-runs\" # repo holding the run checkpoints\n",
|
| 37 |
+
"HF_CODE = \"hieu3636/cxr-vlm-code\" # repo holding the source code\n",
|
| 38 |
+
"STAGE = \"stage2\" # stage2 = instruction-tuned (default)\n",
|
| 39 |
+
"WHICH = \"best\" # best (lowest eval_loss) | last\n",
|
| 40 |
+
"\n",
|
| 41 |
+
"# ── HF token: needed if the runs repo is private. Prefer Colab Secrets. ──\n",
|
| 42 |
+
"try:\n",
|
| 43 |
+
" from google.colab import userdata\n",
|
| 44 |
+
" os.environ[\"HF_TOKEN\"] = userdata.get(\"HF_TOKEN\")\n",
|
| 45 |
+
"except Exception:\n",
|
| 46 |
+
" os.environ.setdefault(\"HF_TOKEN\", \"\") # hoặc dán token trực tiếp vào đây\n",
|
| 47 |
+
"\n",
|
| 48 |
+
"print(\"RUN_ID =\", RUN_ID)\n",
|
| 49 |
+
"print(\"HF_TOKEN set:\", bool(os.environ.get(\"HF_TOKEN\")))"
|
| 50 |
+
]
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"cell_type": "markdown",
|
| 54 |
+
"id": "4b30211d",
|
| 55 |
+
"metadata": {},
|
| 56 |
+
"source": [
|
| 57 |
+
"## 2. Pull source code from HF\n",
|
| 58 |
+
"Download the code repo snapshot to `/content/cxr-vlm-code` and `cd` into it."
|
| 59 |
+
]
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"cell_type": "code",
|
| 63 |
+
"execution_count": null,
|
| 64 |
+
"id": "0a94bc32",
|
| 65 |
+
"metadata": {},
|
| 66 |
+
"outputs": [],
|
| 67 |
+
"source": [
|
| 68 |
+
"from huggingface_hub import snapshot_download\n",
|
| 69 |
+
"import os\n",
|
| 70 |
+
"\n",
|
| 71 |
+
"CODE_DIR = snapshot_download(\n",
|
| 72 |
+
" repo_id=HF_CODE, repo_type='model',\n",
|
| 73 |
+
" token=os.environ.get('HF_TOKEN') or None,\n",
|
| 74 |
+
" local_dir='/content/cxr-vlm-code',\n",
|
| 75 |
+
")\n",
|
| 76 |
+
"os.chdir(CODE_DIR)\n",
|
| 77 |
+
"print('code dir:', CODE_DIR)\n",
|
| 78 |
+
"!ls"
|
| 79 |
+
]
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"cell_type": "markdown",
|
| 83 |
+
"id": "6807e7d1",
|
| 84 |
+
"metadata": {},
|
| 85 |
+
"source": [
|
| 86 |
+
"## 3. Install dependencies\n",
|
| 87 |
+
"Install the pinned versions from `requirements.txt` (including `gradio`). PEFT/hub are pinned to match `transformers==4.49`. May take a few minutes."
|
| 88 |
+
]
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"cell_type": "code",
|
| 92 |
+
"execution_count": null,
|
| 93 |
+
"id": "ff256b17",
|
| 94 |
+
"metadata": {},
|
| 95 |
+
"outputs": [],
|
| 96 |
+
"source": [
|
| 97 |
+
"!pip install -q gradio==5.9.1\n",
|
| 98 |
+
"!pip install -q transformers==4.49.0 peft==0.14.0 accelerate==1.13.0 \\\n",
|
| 99 |
+
" bitsandbytes==0.49.2 'huggingface_hub>=0.27,<1.0' httpx==0.28.1 \\\n",
|
| 100 |
+
" omegaconf==2.3.0 sentencepiece==0.2.1 timm==1.0.26\n",
|
| 101 |
+
"print('done')"
|
| 102 |
+
]
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"cell_type": "markdown",
|
| 106 |
+
"id": "3baf9e4d",
|
| 107 |
+
"metadata": {},
|
| 108 |
+
"source": [
|
| 109 |
+
"## 4. Load model & launch the demo\n",
|
| 110 |
+
"The first run downloads RAD-DINO + Vicuna-7B (4-bit) + your checkpoint — a few minutes. When ready, open the **`https://....gradio.live`** link printed below."
|
| 111 |
+
]
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"cell_type": "code",
|
| 115 |
+
"execution_count": null,
|
| 116 |
+
"id": "a6f60d45",
|
| 117 |
+
"metadata": {},
|
| 118 |
+
"outputs": [],
|
| 119 |
+
"source": [
|
| 120 |
+
"import utils._httpx_compat # noqa: F401 (httpx 0.28 compat, safe)\n",
|
| 121 |
+
"from demo.cxr_demo import CXRChatbot, download_checkpoint_from_hf\n",
|
| 122 |
+
"from demo.app import build_ui\n",
|
| 123 |
+
"\n",
|
| 124 |
+
"ckpt_dir = download_checkpoint_from_hf(\n",
|
| 125 |
+
" repo_id=HF_RUNS, run_id=RUN_ID, stage=STAGE, which=WHICH,\n",
|
| 126 |
+
" token=os.environ.get('HF_TOKEN') or None,\n",
|
| 127 |
+
")\n",
|
| 128 |
+
"print('checkpoint:', ckpt_dir)\n",
|
| 129 |
+
"\n",
|
| 130 |
+
"bot = CXRChatbot(checkpoint_dir=ckpt_dir,\n",
|
| 131 |
+
" model_config='configs/model_config.yaml',\n",
|
| 132 |
+
" device='auto')\n",
|
| 133 |
+
"\n",
|
| 134 |
+
"demo = build_ui(bot, max_new_tokens=300)\n",
|
| 135 |
+
"demo.queue().launch(share=True) # share=True → link công khai *.gradio.live"
|
| 136 |
+
]
|
| 137 |
+
}
|
| 138 |
+
],
|
| 139 |
+
"metadata": {
|
| 140 |
+
"accelerator": "GPU",
|
| 141 |
+
"colab": {
|
| 142 |
+
"provenance": []
|
| 143 |
+
},
|
| 144 |
+
"kernelspec": {
|
| 145 |
+
"display_name": "Python 3",
|
| 146 |
+
"name": "python3"
|
| 147 |
+
},
|
| 148 |
+
"language_info": {
|
| 149 |
+
"name": "python"
|
| 150 |
+
}
|
| 151 |
+
},
|
| 152 |
+
"nbformat": 4,
|
| 153 |
+
"nbformat_minor": 5
|
| 154 |
+
}
|