Image-Text-to-Text
Transformers
ONNX
Safetensors
English
medical
chest-xray
radiology
clip
blip
multimodal
cpu
Instructions to use GAD-Research-Lab/MedicalAI-Light-Weight with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use GAD-Research-Lab/MedicalAI-Light-Weight with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("image-text-to-text", model="GAD-Research-Lab/MedicalAI-Light-Weight")# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("GAD-Research-Lab/MedicalAI-Light-Weight", device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use GAD-Research-Lab/MedicalAI-Light-Weight with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "GAD-Research-Lab/MedicalAI-Light-Weight" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "GAD-Research-Lab/MedicalAI-Light-Weight", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker
docker model run hf.co/GAD-Research-Lab/MedicalAI-Light-Weight
- SGLang
How to use GAD-Research-Lab/MedicalAI-Light-Weight with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "GAD-Research-Lab/MedicalAI-Light-Weight" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "GAD-Research-Lab/MedicalAI-Light-Weight", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "GAD-Research-Lab/MedicalAI-Light-Weight" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "GAD-Research-Lab/MedicalAI-Light-Weight", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }' - Docker Model Runner
How to use GAD-Research-Lab/MedicalAI-Light-Weight with Docker Model Runner:
docker model run hf.co/GAD-Research-Lab/MedicalAI-Light-Weight
Upload models, ONNX exports and application code
Browse files- .gitattributes +6 -30
- .gitignore +8 -0
- batch_predict.py +101 -0
- blip-xray-finetuned/config.json +52 -0
- blip-xray-finetuned/generation_config.json +7 -0
- blip-xray-finetuned/model.safetensors +3 -0
- blip-xray-finetuned/preprocessor_config.json +24 -0
- blip-xray-finetuned/special_tokens_map.json +37 -0
- blip-xray-finetuned/tokenizer.json +0 -0
- blip-xray-finetuned/tokenizer_config.json +63 -0
- blip-xray-finetuned/vocab.txt +0 -0
- build_exe.py +89 -0
- capture.py +204 -0
- checkpoints/fusion_model.pth +3 -0
- checkpoints/onnx/fusion_classifier.onnx +3 -0
- checkpoints/onnx/fusion_classifier.onnx.data +3 -0
- checkpoints/onnx_full/fusion_full.onnx +3 -0
- checkpoints/onnx_full/labels.json +0 -0
- config.json +29 -0
- download_model.py +138 -0
- expand_dataset.py +790 -0
- launch.ps1 +112 -0
- launch.sh +85 -0
- models/default/fusion_classifier.onnx +3 -0
- models/default/fusion_classifier.onnx.data +3 -0
- models/default/labels.json +1 -0
- models/default/symptoms.txt +8 -0
- optimize.py +399 -0
- push_to_hub.py +132 -0
- quantization.py +307 -0
- requirements.txt +18 -0
- run.py +153 -0
- setup_default.py +182 -0
- training.py +349 -0
- update.py +261 -0
- web_demo/index.html +188 -0
- web_ui.py +186 -0
- xray_training.py +273 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,11 @@
|
|
| 1 |
-
*.
|
| 2 |
-
*.
|
|
|
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.
|
|
|
|
| 5 |
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.onnx.data filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
| 8 |
*.h5 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
analyzing_images_for_ai.md
|
| 2 |
+
MODEL_CARD.md
|
| 3 |
+
push_to_hub.py
|
| 4 |
+
.venv/
|
| 5 |
+
blip*/
|
| 6 |
+
data*/
|
| 7 |
+
checkpoints*/
|
| 8 |
+
__pycache__/
|
batch_predict.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Batch prediction script for non-interactive use.
|
| 3 |
+
Processes many X-ray images (or a single one) and outputs results as CSV.
|
| 4 |
+
"""
|
| 5 |
+
import argparse
|
| 6 |
+
import csv
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
from optimize import (
|
| 12 |
+
clear_memory,
|
| 13 |
+
infer_blip,
|
| 14 |
+
infer_fusion,
|
| 15 |
+
infer_fusion_onnx,
|
| 16 |
+
set_cpu_threads,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
CHECKPOINT_PATH = "./checkpoints/fusion_model.pth"
|
| 20 |
+
ONNX_FULL_DIR = "./checkpoints/onnx_full"
|
| 21 |
+
HAS_FUSION = os.path.exists(CHECKPOINT_PATH)
|
| 22 |
+
HAS_ONNX = os.path.exists(os.path.join(ONNX_FULL_DIR, "fusion_full.onnx"))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def find_images(path):
|
| 26 |
+
path = Path(path)
|
| 27 |
+
if path.is_file():
|
| 28 |
+
return [path]
|
| 29 |
+
exts = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".dcm"}
|
| 30 |
+
return sorted([p for p in path.rglob("*") if p.suffix.lower() in exts])
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def process_image(image_path, symptoms, use_vision, use_onnx):
|
| 34 |
+
result = {"image": str(image_path), "symptoms": symptoms}
|
| 35 |
+
|
| 36 |
+
if use_vision:
|
| 37 |
+
try:
|
| 38 |
+
caption = infer_blip(str(image_path), use_onnx=HAS_ONNX)
|
| 39 |
+
result["caption"] = caption
|
| 40 |
+
except Exception as e:
|
| 41 |
+
result["caption"] = f"ERROR: {e}"
|
| 42 |
+
|
| 43 |
+
if HAS_FUSION and symptoms:
|
| 44 |
+
try:
|
| 45 |
+
if use_onnx and HAS_ONNX:
|
| 46 |
+
diagnosis, confidence = infer_fusion_onnx(str(image_path), symptoms)
|
| 47 |
+
else:
|
| 48 |
+
diagnosis, confidence = infer_fusion(str(image_path), symptoms)
|
| 49 |
+
result["diagnosis"] = diagnosis if diagnosis else "INCONCLUSIVE"
|
| 50 |
+
result["confidence"] = f"{confidence:.4f}" if isinstance(confidence, float) else confidence
|
| 51 |
+
except Exception as e:
|
| 52 |
+
result["diagnosis"] = f"ERROR: {e}"
|
| 53 |
+
result["confidence"] = ""
|
| 54 |
+
|
| 55 |
+
clear_memory()
|
| 56 |
+
return result
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def main():
|
| 60 |
+
set_cpu_threads()
|
| 61 |
+
|
| 62 |
+
parser = argparse.ArgumentParser(description="Batch X-ray prediction")
|
| 63 |
+
parser.add_argument("input", help="Path to image file or directory")
|
| 64 |
+
parser.add_argument("--symptoms", default="What abnormality is present in this chest X-ray?",
|
| 65 |
+
help="Symptoms text (same for all images)")
|
| 66 |
+
parser.add_argument("--output", "-o", default="predictions.csv",
|
| 67 |
+
help="Output CSV path")
|
| 68 |
+
parser.add_argument("--vision", action="store_true",
|
| 69 |
+
help="Run vision captioning (BLIP)")
|
| 70 |
+
parser.add_argument("--onnx", action="store_true",
|
| 71 |
+
help="Use ONNX runtime (if exported)")
|
| 72 |
+
args = parser.parse_args()
|
| 73 |
+
|
| 74 |
+
images = find_images(args.input)
|
| 75 |
+
if not images:
|
| 76 |
+
print(f"No images found at: {args.input}")
|
| 77 |
+
sys.exit(1)
|
| 78 |
+
|
| 79 |
+
print(f"Found {len(images)} image(s)")
|
| 80 |
+
print(f"Symptoms: {args.symptoms}")
|
| 81 |
+
print(f"Vision: {'ON' if args.vision else 'OFF'}")
|
| 82 |
+
print(f"ONNX: {'ON' if args.onnx and HAS_ONNX else 'OFF'}")
|
| 83 |
+
print()
|
| 84 |
+
|
| 85 |
+
results = []
|
| 86 |
+
for i, img_path in enumerate(images):
|
| 87 |
+
print(f"[{i+1}/{len(images)}] Processing {img_path.name}...")
|
| 88 |
+
result = process_image(str(img_path), args.symptoms, args.vision, args.onnx)
|
| 89 |
+
results.append(result)
|
| 90 |
+
|
| 91 |
+
fieldnames = ["image", "symptoms", "caption", "diagnosis", "confidence"]
|
| 92 |
+
with open(args.output, "w", newline="", encoding="utf-8") as f:
|
| 93 |
+
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
| 94 |
+
writer.writeheader()
|
| 95 |
+
writer.writerows(results)
|
| 96 |
+
|
| 97 |
+
print(f"\nDone. Results saved to {args.output}")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
main()
|
blip-xray-finetuned/config.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"BlipForConditionalGeneration"
|
| 4 |
+
],
|
| 5 |
+
"dtype": "float32",
|
| 6 |
+
"image_text_hidden_size": 256,
|
| 7 |
+
"initializer_factor": 1.0,
|
| 8 |
+
"initializer_range": 0.02,
|
| 9 |
+
"label_smoothing": 0.0,
|
| 10 |
+
"logit_scale_init_value": 2.6592,
|
| 11 |
+
"model_type": "blip",
|
| 12 |
+
"projection_dim": 512,
|
| 13 |
+
"text_config": {
|
| 14 |
+
"attention_probs_dropout_prob": 0.0,
|
| 15 |
+
"dtype": "float32",
|
| 16 |
+
"encoder_hidden_size": 768,
|
| 17 |
+
"hidden_act": "gelu",
|
| 18 |
+
"hidden_dropout_prob": 0.0,
|
| 19 |
+
"hidden_size": 768,
|
| 20 |
+
"initializer_factor": 1.0,
|
| 21 |
+
"initializer_range": 0.02,
|
| 22 |
+
"intermediate_size": 3072,
|
| 23 |
+
"label_smoothing": 0.0,
|
| 24 |
+
"layer_norm_eps": 1e-12,
|
| 25 |
+
"max_position_embeddings": 512,
|
| 26 |
+
"model_type": "blip_text_model",
|
| 27 |
+
"num_attention_heads": 12,
|
| 28 |
+
"num_hidden_layers": 12,
|
| 29 |
+
"projection_dim": 768,
|
| 30 |
+
"use_cache": true,
|
| 31 |
+
"vocab_size": 30524
|
| 32 |
+
},
|
| 33 |
+
"transformers_version": "4.57.6",
|
| 34 |
+
"vision_config": {
|
| 35 |
+
"attention_dropout": 0.0,
|
| 36 |
+
"dropout": 0.0,
|
| 37 |
+
"dtype": "float32",
|
| 38 |
+
"hidden_act": "gelu",
|
| 39 |
+
"hidden_size": 768,
|
| 40 |
+
"image_size": 384,
|
| 41 |
+
"initializer_factor": 1.0,
|
| 42 |
+
"initializer_range": 0.02,
|
| 43 |
+
"intermediate_size": 3072,
|
| 44 |
+
"layer_norm_eps": 1e-05,
|
| 45 |
+
"model_type": "blip_vision_model",
|
| 46 |
+
"num_attention_heads": 12,
|
| 47 |
+
"num_channels": 3,
|
| 48 |
+
"num_hidden_layers": 12,
|
| 49 |
+
"patch_size": 16,
|
| 50 |
+
"projection_dim": 512
|
| 51 |
+
}
|
| 52 |
+
}
|
blip-xray-finetuned/generation_config.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_from_model_config": true,
|
| 3 |
+
"bos_token_id": 30522,
|
| 4 |
+
"eos_token_id": 2,
|
| 5 |
+
"pad_token_id": 0,
|
| 6 |
+
"transformers_version": "4.57.6"
|
| 7 |
+
}
|
blip-xray-finetuned/model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:aacb477926134f66b9a4c54273df00aa802c28b056f023f11fd390a43fc19943
|
| 3 |
+
size 895947208
|
blip-xray-finetuned/preprocessor_config.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"do_convert_rgb": true,
|
| 3 |
+
"do_normalize": true,
|
| 4 |
+
"do_rescale": true,
|
| 5 |
+
"do_resize": true,
|
| 6 |
+
"image_mean": [
|
| 7 |
+
0.48145466,
|
| 8 |
+
0.4578275,
|
| 9 |
+
0.40821073
|
| 10 |
+
],
|
| 11 |
+
"image_processor_type": "BlipImageProcessor",
|
| 12 |
+
"image_std": [
|
| 13 |
+
0.26862954,
|
| 14 |
+
0.26130258,
|
| 15 |
+
0.27577711
|
| 16 |
+
],
|
| 17 |
+
"processor_class": "BlipProcessor",
|
| 18 |
+
"resample": 3,
|
| 19 |
+
"rescale_factor": 0.00392156862745098,
|
| 20 |
+
"size": {
|
| 21 |
+
"height": 384,
|
| 22 |
+
"width": 384
|
| 23 |
+
}
|
| 24 |
+
}
|
blip-xray-finetuned/special_tokens_map.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cls_token": {
|
| 3 |
+
"content": "[CLS]",
|
| 4 |
+
"lstrip": false,
|
| 5 |
+
"normalized": false,
|
| 6 |
+
"rstrip": false,
|
| 7 |
+
"single_word": false
|
| 8 |
+
},
|
| 9 |
+
"mask_token": {
|
| 10 |
+
"content": "[MASK]",
|
| 11 |
+
"lstrip": false,
|
| 12 |
+
"normalized": false,
|
| 13 |
+
"rstrip": false,
|
| 14 |
+
"single_word": false
|
| 15 |
+
},
|
| 16 |
+
"pad_token": {
|
| 17 |
+
"content": "[PAD]",
|
| 18 |
+
"lstrip": false,
|
| 19 |
+
"normalized": false,
|
| 20 |
+
"rstrip": false,
|
| 21 |
+
"single_word": false
|
| 22 |
+
},
|
| 23 |
+
"sep_token": {
|
| 24 |
+
"content": "[SEP]",
|
| 25 |
+
"lstrip": false,
|
| 26 |
+
"normalized": false,
|
| 27 |
+
"rstrip": false,
|
| 28 |
+
"single_word": false
|
| 29 |
+
},
|
| 30 |
+
"unk_token": {
|
| 31 |
+
"content": "[UNK]",
|
| 32 |
+
"lstrip": false,
|
| 33 |
+
"normalized": false,
|
| 34 |
+
"rstrip": false,
|
| 35 |
+
"single_word": false
|
| 36 |
+
}
|
| 37 |
+
}
|
blip-xray-finetuned/tokenizer.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
blip-xray-finetuned/tokenizer_config.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"added_tokens_decoder": {
|
| 3 |
+
"0": {
|
| 4 |
+
"content": "[PAD]",
|
| 5 |
+
"lstrip": false,
|
| 6 |
+
"normalized": false,
|
| 7 |
+
"rstrip": false,
|
| 8 |
+
"single_word": false,
|
| 9 |
+
"special": true
|
| 10 |
+
},
|
| 11 |
+
"100": {
|
| 12 |
+
"content": "[UNK]",
|
| 13 |
+
"lstrip": false,
|
| 14 |
+
"normalized": false,
|
| 15 |
+
"rstrip": false,
|
| 16 |
+
"single_word": false,
|
| 17 |
+
"special": true
|
| 18 |
+
},
|
| 19 |
+
"101": {
|
| 20 |
+
"content": "[CLS]",
|
| 21 |
+
"lstrip": false,
|
| 22 |
+
"normalized": false,
|
| 23 |
+
"rstrip": false,
|
| 24 |
+
"single_word": false,
|
| 25 |
+
"special": true
|
| 26 |
+
},
|
| 27 |
+
"102": {
|
| 28 |
+
"content": "[SEP]",
|
| 29 |
+
"lstrip": false,
|
| 30 |
+
"normalized": false,
|
| 31 |
+
"rstrip": false,
|
| 32 |
+
"single_word": false,
|
| 33 |
+
"special": true
|
| 34 |
+
},
|
| 35 |
+
"103": {
|
| 36 |
+
"content": "[MASK]",
|
| 37 |
+
"lstrip": false,
|
| 38 |
+
"normalized": false,
|
| 39 |
+
"rstrip": false,
|
| 40 |
+
"single_word": false,
|
| 41 |
+
"special": true
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
"clean_up_tokenization_spaces": false,
|
| 45 |
+
"cls_token": "[CLS]",
|
| 46 |
+
"do_basic_tokenize": true,
|
| 47 |
+
"do_lower_case": true,
|
| 48 |
+
"extra_special_tokens": {},
|
| 49 |
+
"mask_token": "[MASK]",
|
| 50 |
+
"model_input_names": [
|
| 51 |
+
"input_ids",
|
| 52 |
+
"attention_mask"
|
| 53 |
+
],
|
| 54 |
+
"model_max_length": 512,
|
| 55 |
+
"never_split": null,
|
| 56 |
+
"pad_token": "[PAD]",
|
| 57 |
+
"processor_class": "BlipProcessor",
|
| 58 |
+
"sep_token": "[SEP]",
|
| 59 |
+
"strip_accents": null,
|
| 60 |
+
"tokenize_chinese_chars": true,
|
| 61 |
+
"tokenizer_class": "BertTokenizer",
|
| 62 |
+
"unk_token": "[UNK]"
|
| 63 |
+
}
|
blip-xray-finetuned/vocab.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
build_exe.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build a standalone .exe for Windows using PyInstaller.
|
| 3 |
+
No Python installation needed on the target machine.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python build_exe.py # build web_ui.exe
|
| 7 |
+
python build_exe.py --cli # build run.exe (CLI)
|
| 8 |
+
python build_exe.py --all # build both
|
| 9 |
+
|
| 10 |
+
Requires: pip install pyinstaller
|
| 11 |
+
"""
|
| 12 |
+
import argparse
|
| 13 |
+
import os
|
| 14 |
+
import shutil
|
| 15 |
+
import subprocess
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
DIST_DIR = "./dist"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def build_exe(script, name=None):
|
| 22 |
+
if name is None:
|
| 23 |
+
name = os.path.splitext(os.path.basename(script))[0]
|
| 24 |
+
|
| 25 |
+
print(f"[cyan]Building {name}.exe from {script}...[/cyan]")
|
| 26 |
+
|
| 27 |
+
cmd = [
|
| 28 |
+
sys.executable, "-m", "PyInstaller",
|
| 29 |
+
"--onefile",
|
| 30 |
+
"--console",
|
| 31 |
+
"--name", name,
|
| 32 |
+
"--distpath", DIST_DIR,
|
| 33 |
+
"--workpath", "./build",
|
| 34 |
+
"--specpath", "./build",
|
| 35 |
+
"--add-data", "config.json;.",
|
| 36 |
+
script,
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
subprocess.check_call(cmd)
|
| 40 |
+
|
| 41 |
+
exe_path = os.path.join(DIST_DIR, f"{name}.exe")
|
| 42 |
+
if os.path.exists(exe_path):
|
| 43 |
+
size_mb = os.path.getsize(exe_path) / 1024 / 1024
|
| 44 |
+
print(f"[green] Created: {exe_path} ({size_mb:.0f} MB)[/green]")
|
| 45 |
+
else:
|
| 46 |
+
print(f"[red] Failed to create {name}.exe[/red]")
|
| 47 |
+
|
| 48 |
+
# Clean up build artifacts
|
| 49 |
+
for d in ["./build", "*.spec"]:
|
| 50 |
+
try:
|
| 51 |
+
if os.path.isdir(d):
|
| 52 |
+
shutil.rmtree(d)
|
| 53 |
+
except Exception:
|
| 54 |
+
pass
|
| 55 |
+
for f in os.listdir("."):
|
| 56 |
+
if f.endswith(".spec"):
|
| 57 |
+
os.remove(f)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def main():
|
| 61 |
+
parser = argparse.ArgumentParser(description="Build standalone executable")
|
| 62 |
+
parser.add_argument("--cli", action="store_true", help="Build CLI executable")
|
| 63 |
+
parser.add_argument("--all", action="store_true", help="Build all executables")
|
| 64 |
+
args = parser.parse_args()
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
import PyInstaller # noqa: F401
|
| 68 |
+
except ImportError:
|
| 69 |
+
print("PyInstaller is required. Install with: pip install pyinstaller")
|
| 70 |
+
sys.exit(1)
|
| 71 |
+
|
| 72 |
+
os.makedirs(DIST_DIR, exist_ok=True)
|
| 73 |
+
|
| 74 |
+
if args.all:
|
| 75 |
+
build_exe("web_ui.py")
|
| 76 |
+
build_exe("run.py")
|
| 77 |
+
build_exe("batch_predict.py")
|
| 78 |
+
elif args.cli:
|
| 79 |
+
build_exe("run.py")
|
| 80 |
+
else:
|
| 81 |
+
build_exe("web_ui.py")
|
| 82 |
+
|
| 83 |
+
print(f"\n[green]Done. Executables in ./{DIST_DIR}/[/green]")
|
| 84 |
+
print("[yellow]Note: The .exe still needs model files (checkpoints/).[/yellow]")
|
| 85 |
+
print("[yellow]Copy the entire project folder to the target machine.[/yellow]")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
main()
|
capture.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import importlib
|
| 2 |
+
import os
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
import tempfile
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from PIL import Image, ImageOps
|
| 10 |
+
|
| 11 |
+
DATA_DIR = Path("./data")
|
| 12 |
+
IMAGES_DIR = DATA_DIR / "images"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _ensure_dep(package_name, import_name=None):
|
| 16 |
+
if import_name is None:
|
| 17 |
+
import_name = package_name
|
| 18 |
+
try:
|
| 19 |
+
return importlib.import_module(import_name)
|
| 20 |
+
except ImportError:
|
| 21 |
+
from rich.console import Console
|
| 22 |
+
console = Console()
|
| 23 |
+
console.print(f"[yellow]'{package_name}' is required for this feature.[/yellow]")
|
| 24 |
+
import questionary
|
| 25 |
+
install = questionary.confirm(f"Install {package_name} now?", default=True).ask()
|
| 26 |
+
if not install:
|
| 27 |
+
return None
|
| 28 |
+
console.print(f"[cyan]Installing {package_name}...[/cyan]")
|
| 29 |
+
subprocess.check_call([sys.executable, "-m", "pip", "install", package_name])
|
| 30 |
+
return importlib.import_module(import_name)
|
| 31 |
+
|
| 32 |
+
def _ensure_dirs():
|
| 33 |
+
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
|
| 35 |
+
def _save_image(pil_image, prefix="capture"):
|
| 36 |
+
_ensure_dirs()
|
| 37 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 38 |
+
filename = f"{prefix}_{timestamp}.jpg"
|
| 39 |
+
path = str(IMAGES_DIR / filename)
|
| 40 |
+
if pil_image.mode != "RGB":
|
| 41 |
+
pil_image = pil_image.convert("RGB")
|
| 42 |
+
pil_image.save(path, quality=95)
|
| 43 |
+
return path
|
| 44 |
+
|
| 45 |
+
# ── Camera ──────────────────────────────────────────────────────
|
| 46 |
+
|
| 47 |
+
def capture_camera():
|
| 48 |
+
cv2 = _ensure_dep("opencv-python", "cv2")
|
| 49 |
+
if cv2 is None:
|
| 50 |
+
return None, "Camera capture requires opencv-python"
|
| 51 |
+
|
| 52 |
+
cap = cv2.VideoCapture(0)
|
| 53 |
+
if not cap.isOpened():
|
| 54 |
+
return None, "No camera detected (could not open index 0)"
|
| 55 |
+
|
| 56 |
+
from rich.console import Console
|
| 57 |
+
console = Console()
|
| 58 |
+
console.print("[cyan]Camera opened. Press SPACE to capture, ESC to cancel.[/cyan]")
|
| 59 |
+
|
| 60 |
+
import questionary
|
| 61 |
+
input("Press Enter when ready for camera preview...")
|
| 62 |
+
ret, frame = cap.read()
|
| 63 |
+
cap.release()
|
| 64 |
+
|
| 65 |
+
if not ret:
|
| 66 |
+
return None, "Failed to capture frame from camera"
|
| 67 |
+
|
| 68 |
+
preview_path = tempfile.mktemp(suffix="_preview.jpg")
|
| 69 |
+
cv2.imwrite(preview_path, frame)
|
| 70 |
+
preview = Image.open(preview_path)
|
| 71 |
+
os.unlink(preview_path)
|
| 72 |
+
|
| 73 |
+
console.print("[cyan]Image captured from camera.[/cyan]")
|
| 74 |
+
path = _save_image(preview, "camera")
|
| 75 |
+
return path, f"Captured from camera -> {path}"
|
| 76 |
+
|
| 77 |
+
# ── File browser ───────────────────────────────────────────────
|
| 78 |
+
|
| 79 |
+
def capture_file():
|
| 80 |
+
try:
|
| 81 |
+
import tkinter as tk
|
| 82 |
+
from tkinter import filedialog
|
| 83 |
+
root = tk.Tk()
|
| 84 |
+
root.withdraw()
|
| 85 |
+
root.attributes("-topmost", True)
|
| 86 |
+
path = filedialog.askopenfilename(
|
| 87 |
+
title="Select an X-ray image",
|
| 88 |
+
filetypes=[
|
| 89 |
+
("Image files", "*.jpg *.jpeg *.png *.bmp *.tif *.tiff *.dcm"),
|
| 90 |
+
("All files", "*.*"),
|
| 91 |
+
],
|
| 92 |
+
)
|
| 93 |
+
root.destroy()
|
| 94 |
+
except Exception as e:
|
| 95 |
+
return None, f"File dialog failed: {e}"
|
| 96 |
+
|
| 97 |
+
if not path:
|
| 98 |
+
return None, "No file selected"
|
| 99 |
+
|
| 100 |
+
return _open_and_save(path)
|
| 101 |
+
|
| 102 |
+
# ── Manual path entry ──────────────────────────────────────────
|
| 103 |
+
|
| 104 |
+
def capture_path():
|
| 105 |
+
from rich.console import Console
|
| 106 |
+
console = Console()
|
| 107 |
+
console.print("[cyan]Enter the path to an X-ray image file.[/cyan]")
|
| 108 |
+
|
| 109 |
+
import questionary
|
| 110 |
+
path = questionary.path("Image path:").ask()
|
| 111 |
+
if not path:
|
| 112 |
+
return None, "No path entered"
|
| 113 |
+
return _open_and_save(path)
|
| 114 |
+
|
| 115 |
+
# ── DICOM ──────────────────────────────────────────────────────
|
| 116 |
+
|
| 117 |
+
def capture_dicom(path=None):
|
| 118 |
+
pydicom = _ensure_dep("pydicom")
|
| 119 |
+
if pydicom is None:
|
| 120 |
+
return None, "DICOM loading requires pydicom"
|
| 121 |
+
np = _ensure_dep("numpy")
|
| 122 |
+
if np is None:
|
| 123 |
+
return None, "DICOM loading requires numpy"
|
| 124 |
+
|
| 125 |
+
if not path:
|
| 126 |
+
try:
|
| 127 |
+
import tkinter as tk
|
| 128 |
+
from tkinter import filedialog
|
| 129 |
+
root = tk.Tk()
|
| 130 |
+
root.withdraw()
|
| 131 |
+
root.attributes("-topmost", True)
|
| 132 |
+
path = filedialog.askopenfilename(
|
| 133 |
+
title="Select a DICOM file",
|
| 134 |
+
filetypes=[("DICOM files", "*.dcm"), ("All files", "*.*")],
|
| 135 |
+
)
|
| 136 |
+
root.destroy()
|
| 137 |
+
except Exception as e:
|
| 138 |
+
return None, f"File dialog failed: {e}"
|
| 139 |
+
|
| 140 |
+
if not path:
|
| 141 |
+
return None, "No DICOM file selected"
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
ds = pydicom.dcmread(path)
|
| 145 |
+
arr = ds.pixel_array
|
| 146 |
+
arr = arr - arr.min()
|
| 147 |
+
arr = (arr / arr.max() * 255).astype(np.uint8)
|
| 148 |
+
if len(arr.shape) == 2:
|
| 149 |
+
img = Image.fromarray(arr, mode="L")
|
| 150 |
+
img = ImageOps.equalize(img)
|
| 151 |
+
else:
|
| 152 |
+
img = Image.fromarray(arr)
|
| 153 |
+
result_path = _save_image(img, "dicom")
|
| 154 |
+
return result_path, f"DICOM loaded from {path} -> saved as {result_path}"
|
| 155 |
+
except Exception as e:
|
| 156 |
+
return None, f"Failed to read DICOM: {e}"
|
| 157 |
+
|
| 158 |
+
# ── Generic open + save ────────────────────────────────────────
|
| 159 |
+
|
| 160 |
+
def _open_and_save(source_path):
|
| 161 |
+
source_path = str(source_path)
|
| 162 |
+
if source_path.lower().endswith(".dcm"):
|
| 163 |
+
return capture_dicom(source_path)
|
| 164 |
+
try:
|
| 165 |
+
img = Image.open(source_path)
|
| 166 |
+
path = _save_image(img, "import")
|
| 167 |
+
return path, f"Imported from {source_path} -> {path}"
|
| 168 |
+
except Exception as e:
|
| 169 |
+
return None, f"Failed to open image: {e}"
|
| 170 |
+
|
| 171 |
+
# ── Top-level picker ───────────────────────────────────────────
|
| 172 |
+
|
| 173 |
+
def pick_image():
|
| 174 |
+
from rich.console import Console
|
| 175 |
+
import questionary
|
| 176 |
+
|
| 177 |
+
console = Console()
|
| 178 |
+
method = questionary.select(
|
| 179 |
+
"How do you want to provide the X-ray image?",
|
| 180 |
+
choices=[
|
| 181 |
+
"Browse files on computer",
|
| 182 |
+
"Enter file path manually",
|
| 183 |
+
"Capture from camera",
|
| 184 |
+
"Load DICOM file",
|
| 185 |
+
],
|
| 186 |
+
pointer=">",
|
| 187 |
+
).ask()
|
| 188 |
+
|
| 189 |
+
result = None
|
| 190 |
+
if method == "Browse files on computer":
|
| 191 |
+
result = capture_file()
|
| 192 |
+
elif method == "Enter file path manually":
|
| 193 |
+
result = capture_path()
|
| 194 |
+
elif method == "Capture from camera":
|
| 195 |
+
result = capture_camera()
|
| 196 |
+
elif method == "Load DICOM file":
|
| 197 |
+
result = capture_dicom()
|
| 198 |
+
|
| 199 |
+
return result
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
if __name__ == "__main__":
|
| 203 |
+
path, msg = pick_image()
|
| 204 |
+
print(msg)
|
checkpoints/fusion_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:dcca5c883f0dc27e3b119fe79d5d58a029264dac87b0fe9aa4aead941680ae75
|
| 3 |
+
size 5389135
|
checkpoints/onnx/fusion_classifier.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:217e6206818cb0c72b40076eef6c0f732063be2a462e06ec935587e39305206b
|
| 3 |
+
size 4876
|
checkpoints/onnx/fusion_classifier.onnx.data
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a851183f21595233f4c72cd4d1fbdd0250f9b3f654a5b545be978b9d4c72d9db
|
| 3 |
+
size 4466688
|
checkpoints/onnx_full/fusion_full.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:13c6bad7df618201a2a04e06a9c7f87970c72d33689ec812c1228cfe6aaccffa
|
| 3 |
+
size 787241423
|
checkpoints/onnx_full/labels.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
config.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "MedicalAI - Light Weight configuration. Edit this file instead of Python code.",
|
| 3 |
+
|
| 4 |
+
"device": "auto",
|
| 5 |
+
"cpu_threads": "auto",
|
| 6 |
+
"use_fp16": "auto",
|
| 7 |
+
"use_onnx": true,
|
| 8 |
+
|
| 9 |
+
"data_dir": "./data",
|
| 10 |
+
"csv_path": "./data/dataset.csv",
|
| 11 |
+
"images_dir": "./data/images",
|
| 12 |
+
|
| 13 |
+
"checkpoint_dir": "./checkpoints",
|
| 14 |
+
"onnx_dir": "./checkpoints/onnx",
|
| 15 |
+
"onnx_full_dir": "./checkpoints/onnx_full",
|
| 16 |
+
|
| 17 |
+
"blip_model_dir": "./blip-xray-finetuned",
|
| 18 |
+
"blip_model_name": "Salesforce/blip-image-captioning-base",
|
| 19 |
+
|
| 20 |
+
"fusion_checkpoint": "./checkpoints/fusion_model.pth",
|
| 21 |
+
|
| 22 |
+
"confidence_threshold": 0.75,
|
| 23 |
+
"max_symptom_length": 64,
|
| 24 |
+
"web_port": 7860,
|
| 25 |
+
"web_host": "127.0.0.1",
|
| 26 |
+
"api_port": 8000,
|
| 27 |
+
"api_host": "127.0.0.1",
|
| 28 |
+
"api_workers": 2
|
| 29 |
+
}
|
download_model.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download pre-trained checkpoints so users can skip training from scratch.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python download_model.py # interactive menu
|
| 6 |
+
python download_model.py --all # download everything
|
| 7 |
+
python download_model.py --fusion # fusion model only
|
| 8 |
+
python download_model.py --blip-finetuned # fine-tuned BLIP only
|
| 9 |
+
"""
|
| 10 |
+
import argparse
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
BASE_URL = "https://huggingface.co/your-org/medicalai-lightweight/resolve/main"
|
| 16 |
+
CHECKPOINT_DIR = Path("./checkpoints")
|
| 17 |
+
BLIP_DIR = Path("./blip-xray-finetuned")
|
| 18 |
+
ONNX_DIR = CHECKPOINT_DIR / "onnx_full"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _ensure_dir(path):
|
| 22 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _download_file(url, dest):
|
| 26 |
+
import requests
|
| 27 |
+
from rich.console import Console
|
| 28 |
+
from rich.progress import Progress, BarColumn, DownloadColumn, TextColumn
|
| 29 |
+
|
| 30 |
+
console = Console()
|
| 31 |
+
console.print(f"[cyan]Downloading {url.split('/')[-1]}...[/cyan]")
|
| 32 |
+
|
| 33 |
+
resp = requests.get(url, stream=True)
|
| 34 |
+
resp.raise_for_status()
|
| 35 |
+
total = int(resp.headers.get("content-length", 0))
|
| 36 |
+
|
| 37 |
+
with Progress(
|
| 38 |
+
TextColumn("[cyan] Download[/cyan]"),
|
| 39 |
+
BarColumn(),
|
| 40 |
+
DownloadColumn(),
|
| 41 |
+
transient=True,
|
| 42 |
+
) as progress:
|
| 43 |
+
task = progress.add_task("", total=total)
|
| 44 |
+
with open(dest, "wb") as f:
|
| 45 |
+
for chunk in resp.iter_content(chunk_size=8192):
|
| 46 |
+
f.write(chunk)
|
| 47 |
+
progress.update(task, advance=len(chunk))
|
| 48 |
+
|
| 49 |
+
console.print(f"[green] Saved to {dest}[/green]")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def download_fusion():
|
| 53 |
+
_ensure_dir(CHECKPOINT_DIR)
|
| 54 |
+
_ensure_dir(ONNX_DIR)
|
| 55 |
+
|
| 56 |
+
files = [
|
| 57 |
+
("fusion_model.pth", CHECKPOINT_DIR / "fusion_model.pth"),
|
| 58 |
+
("fusion_full.onnx", ONNX_DIR / "fusion_full.onnx"),
|
| 59 |
+
("labels.json", ONNX_DIR / "labels.json"),
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
for fname, dest in files:
|
| 63 |
+
url = f"{BASE_URL}/{fname}"
|
| 64 |
+
print(f" Would download: {url} -> {dest}")
|
| 65 |
+
|
| 66 |
+
print()
|
| 67 |
+
print("[yellow]Note: Pre-trained checkpoints are not yet hosted.[/yellow]")
|
| 68 |
+
print("[yellow]Train locally with: python training.py --mode prepare-data && python training.py --mode train[/yellow]")
|
| 69 |
+
print("[yellow]Then export: python quantization.py --mode export-full[/yellow]")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def download_blip():
|
| 73 |
+
_ensure_dir(BLIP_DIR)
|
| 74 |
+
|
| 75 |
+
files = [
|
| 76 |
+
"config.json",
|
| 77 |
+
"model.safetensors",
|
| 78 |
+
"preprocessor_config.json",
|
| 79 |
+
"special_tokens_map.json",
|
| 80 |
+
"tokenizer.json",
|
| 81 |
+
"tokenizer_config.json",
|
| 82 |
+
"vocab.txt",
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
for fname in files:
|
| 86 |
+
url = f"{BASE_URL}/blip-xray-finetuned/{fname}"
|
| 87 |
+
dest = BLIP_DIR / fname
|
| 88 |
+
print(f" Would download: {url} -> {dest}")
|
| 89 |
+
|
| 90 |
+
print()
|
| 91 |
+
print("[yellow]Note: Fine-tuned BLIP is not yet hosted.[/yellow]")
|
| 92 |
+
print("[yellow]Train locally with: python xray_training.py --mode train[/yellow]")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def main():
|
| 96 |
+
parser = argparse.ArgumentParser(description="Download pre-trained models")
|
| 97 |
+
parser.add_argument("--all", action="store_true", help="Download everything")
|
| 98 |
+
parser.add_argument("--fusion", action="store_true", help="Download fusion model")
|
| 99 |
+
parser.add_argument("--blip-finetuned", action="store_true", help="Download fine-tuned BLIP")
|
| 100 |
+
args = parser.parse_args()
|
| 101 |
+
|
| 102 |
+
if not any([args.all, args.fusion, args.blip_finetuned]):
|
| 103 |
+
from rich.console import Console
|
| 104 |
+
import questionary
|
| 105 |
+
console = Console()
|
| 106 |
+
console.print("[bold cyan]Download Pre-trained Models[/bold cyan]")
|
| 107 |
+
choice = questionary.select(
|
| 108 |
+
"What would you like to download?",
|
| 109 |
+
choices=[
|
| 110 |
+
"Fusion model (Symptom Check) — ONNX + PyTorch",
|
| 111 |
+
"Fine-tuned BLIP (Vision captioning)",
|
| 112 |
+
"Everything",
|
| 113 |
+
"Cancel",
|
| 114 |
+
],
|
| 115 |
+
).ask()
|
| 116 |
+
if choice == "Cancel":
|
| 117 |
+
return
|
| 118 |
+
if choice == "Fusion model (Symptom Check) — ONNX + PyTorch":
|
| 119 |
+
args.fusion = True
|
| 120 |
+
elif choice == "Fine-tuned BLIP (Vision captioning)":
|
| 121 |
+
args.blip_finetuned = True
|
| 122 |
+
else:
|
| 123 |
+
args.all = True
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
import requests
|
| 127 |
+
except ImportError:
|
| 128 |
+
print("'requests' is required. Install with: pip install requests")
|
| 129 |
+
sys.exit(1)
|
| 130 |
+
|
| 131 |
+
if args.all or args.fusion:
|
| 132 |
+
download_fusion()
|
| 133 |
+
if args.all or args.blip_finetuned:
|
| 134 |
+
download_blip()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
main()
|
expand_dataset.py
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import csv
|
| 2 |
+
import os
|
| 3 |
+
import random
|
| 4 |
+
|
| 5 |
+
CSV_PATH = "./data/dataset.csv"
|
| 6 |
+
|
| 7 |
+
def count_rows():
|
| 8 |
+
with open(CSV_PATH, newline="", encoding="utf-8") as f:
|
| 9 |
+
return sum(1 for _ in csv.DictReader(f))
|
| 10 |
+
|
| 11 |
+
# Symptom templates (much more varied than just the default one)
|
| 12 |
+
SYMPTOM_TEMPLATES = [
|
| 13 |
+
"What abnormality is present in this chest X-ray?",
|
| 14 |
+
"Patient presents with shortness of breath and cough. What is the diagnosis?",
|
| 15 |
+
"Routine pre-operative chest X-ray. Any abnormal findings?",
|
| 16 |
+
"Patient with history of smoking. Evaluate for lung pathology.",
|
| 17 |
+
"Fever and productive cough for 5 days. Assess for pneumonia.",
|
| 18 |
+
"Chest pain and dyspnea on exertion. Cardiac or pulmonary cause?",
|
| 19 |
+
"Post-surgical follow-up. Evaluate lung expansion and complications.",
|
| 20 |
+
"Patient with known COPD. Assess for acute changes or infection.",
|
| 21 |
+
"Trauma patient. Evaluate for pneumothorax, hemothorax, or fractures.",
|
| 22 |
+
"Immunocompromised patient with fever. Opportunistic infection?",
|
| 23 |
+
"Patient with weight loss and night sweats. Evaluate for TB or malignancy.",
|
| 24 |
+
"Dysphagia and regurgitation. Evaluate for hiatal hernia or mediastinal mass.",
|
| 25 |
+
"Pre-employment screening chest X-ray.",
|
| 26 |
+
"Congestive heart failure follow-up. Evaluate for pulmonary edema.",
|
| 27 |
+
"Patient with known interstitial lung disease. Assess for progression.",
|
| 28 |
+
"Central cyanosis and clubbing. Evaluate for congenital heart disease.",
|
| 29 |
+
"Hemoptysis for 2 weeks. Evaluate for bronchiectasis or mass.",
|
| 30 |
+
"Contact TB patient. Screening chest X-ray.",
|
| 31 |
+
"Pre-operative clearance for knee replacement surgery.",
|
| 32 |
+
"Rheumatoid arthritis patient with new dyspnea. Interstitial lung disease?",
|
| 33 |
+
"HIV positive patient with cough and fever.",
|
| 34 |
+
"Post-chemotherapy evaluation. Neutropenic fever.",
|
| 35 |
+
"Patient with asbestos exposure history. Routine surveillance.",
|
| 36 |
+
"Hoarseness and cough. Evaluate for mediastinal mass or recurrent laryngeal nerve involvement.",
|
| 37 |
+
"Chest trauma after MVA. Evaluate for aortic injury.",
|
| 38 |
+
"Patient on chronic steroids. Evaluate for opportunistic infection.",
|
| 39 |
+
"Pre-renal transplant evaluation chest X-ray.",
|
| 40 |
+
"Suspected foreign body aspiration in elderly patient.",
|
| 41 |
+
"Evaluation for pulmonary metastasis in patient with known primary malignancy.",
|
| 42 |
+
"Post-operative CABG. Evaluate for complications, effusion, or pneumothorax.",
|
| 43 |
+
"Rule out tuberculosis in patient with positive PPD.",
|
| 44 |
+
"Dyspnea and orthopnea. Evaluate for heart failure.",
|
| 45 |
+
"Chest wall deformity. Evaluate spine and thoracic cage.",
|
| 46 |
+
"Liver cirrhosis patient with dyspnea. Hepatic hydrothorax?",
|
| 47 |
+
"Pancreatitis patient with respiratory distress.",
|
| 48 |
+
"ARDS follow-up chest X-ray.",
|
| 49 |
+
"Ventilator-associated pneumonia surveillance.",
|
| 50 |
+
"Neonatal respiratory distress. Evaluate for congenital anomalies.",
|
| 51 |
+
"Suspected pulmonary embolism. Evaluate for Hampton's hump or Westermark sign.",
|
| 52 |
+
"Chronic cough with mucus production. Bronchiectasis evaluation.",
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
# List of image paths to reuse (cycling through existing images)
|
| 56 |
+
def get_existing_images():
|
| 57 |
+
images = []
|
| 58 |
+
with open(CSV_PATH, newline="", encoding="utf-8") as f:
|
| 59 |
+
for row in csv.DictReader(f):
|
| 60 |
+
p = row.get("image_path", "").strip()
|
| 61 |
+
if p and p not in images:
|
| 62 |
+
images.append(p)
|
| 63 |
+
if not images:
|
| 64 |
+
images = [f"./data\\images\\iu_xray_{i}.jpg" for i in range(1500)]
|
| 65 |
+
return images
|
| 66 |
+
|
| 67 |
+
IMAGES = get_existing_images()
|
| 68 |
+
|
| 69 |
+
# Massive list of FINDINGS + IMPRESSION entries covering common AND obscure conditions
|
| 70 |
+
DIAGNOSIS_ENTRIES = [
|
| 71 |
+
# ── Normal / No Finding ──
|
| 72 |
+
{
|
| 73 |
+
"diagnosis": "FINDINGS: The cardiomediastinal silhouette is within normal limits. The lungs are clear without focal consolidation, pleural effusion, or pneumothorax. The bony thorax is intact. IMPRESSION: Normal chest radiograph. No acute cardiopulmonary abnormality.",
|
| 74 |
+
"labels": "No Finding",
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"diagnosis": "FINDINGS: Heart size normal. Mediastinal contours normal. Lungs clear bilaterally. Pulmonary vascularity normal. No pleural effusions or pneumothoraces. IMPRESSION: Normal chest examination. No active disease.",
|
| 78 |
+
"labels": "No Finding",
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"diagnosis": "FINDINGS: Clear lungs with no infiltrates, masses, or nodules. Normal cardiac silhouette. No pleural effusion or pneumothorax. IMPRESSION: No acute cardiopulmonary disease.",
|
| 82 |
+
"labels": "No Finding",
|
| 83 |
+
},
|
| 84 |
+
|
| 85 |
+
# ── Cardiomegaly / Heart Failure ──
|
| 86 |
+
{
|
| 87 |
+
"diagnosis": "FINDINGS: The cardiac silhouette is moderately enlarged. Pulmonary vascularity is increased with cephalization of the upper lobe vessels. Small bilateral pleural effusions. No pneumothorax. IMPRESSION: Cardiomegaly with signs of congestive heart failure and pulmonary vascular congestion.",
|
| 88 |
+
"labels": "Cardiomegaly|Edema|Effusion",
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"diagnosis": "FINDINGS: Severe cardiomegaly. Diffuse bilateral interstitial opacities with Kerley B lines in the costophrenic angles. Bilateral pleural effusions, right greater than left. No pneumothorax. IMPRESSION: Severe cardiomegaly with interstitial pulmonary edema and bilateral pleural effusions consistent with congestive heart failure.",
|
| 92 |
+
"labels": "Cardiomegaly|Edema|Effusion",
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"diagnosis": "FINDINGS: Moderate cardiomegaly. Prominent pulmonary vasculature with perihilar bat-wing distribution of airspace opacities. Small bilateral pleural effusions. No pneumothorax. IMPRESSION: Acute pulmonary edema superimposed on chronic cardiomegaly.",
|
| 96 |
+
"labels": "Cardiomegaly|Edema",
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"diagnosis": "FINDINGS: Borderline cardiomegaly. Redistribution of pulmonary blood flow to the upper lobes. No frank pulmonary edema. No pleural effusion. IMPRESSION: Mild cardiomegaly with early pulmonary venous hypertension.",
|
| 100 |
+
"labels": "Cardiomegaly",
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"diagnosis": "FINDINGS: The cardiac silhouette is severely enlarged with a globular configuration. The pulmonary vascularity is within normal limits. No pleural effusion or pneumothorax. The lungs are clear. IMPRESSION: Severe cardiomegaly, possibly pericardial effusion. Correlation with echocardiogram recommended.",
|
| 104 |
+
"labels": "Cardiomegaly",
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"diagnosis": "FINDINGS: Moderate cardiomegaly with a prominent left atrial appendage. Double density sign present. Splayed carina. No pulmonary edema. No pleural effusion. IMPRESSION: Cardiomegaly with left atrial enlargement, consider mitral valve disease.",
|
| 108 |
+
"labels": "Cardiomegaly",
|
| 109 |
+
},
|
| 110 |
+
|
| 111 |
+
# ── Pneumonia (various types) ──
|
| 112 |
+
{
|
| 113 |
+
"diagnosis": "FINDINGS: Dense airspace consolidation in the right lower lobe with obscuration of the right hemidiaphragm. Air bronchograms present. No pleural effusion. No pneumothorax. IMPRESSION: Right lower lobe pneumonia.",
|
| 114 |
+
"labels": "Consolidation|Infiltration",
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"diagnosis": "FINDINGS: Patchy airspace opacities in the left upper lobe with ill-defined margins. No cavitation. No effusion. No pneumothorax. IMPRESSION: Left upper lobe pneumonia. Recommend follow-up to document resolution.",
|
| 118 |
+
"labels": "Consolidation|Infiltration",
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
"diagnosis": "FINDINGS: Multifocal bilateral patchy and confluent airspace opacities in a peribronchovascular distribution. No definite cavitation. Small bilateral effusions. No pneumothorax. IMPRESSION: Multifocal pneumonia, consider atypical or viral etiology.",
|
| 122 |
+
"labels": "Infiltration|Effusion",
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"diagnosis": "FINDINGS: Rounded opacity in the right middle lobe with a positive silhouette sign against the right heart border. Air bronchograms visible. No effusion. IMPRESSION: Right middle lobe pneumonia (silhouette sign present).",
|
| 126 |
+
"labels": "Consolidation|Infiltration",
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"diagnosis": "FINDINGS: Extensive left lower lobe consolidation obscuring the left hemidiaphragm and descending aorta. Air bronchograms are present. Small left pleural effusion. No pneumothorax. IMPRESSION: Left lower lobe pneumonia with parapneumonic effusion.",
|
| 130 |
+
"labels": "Consolidation|Effusion|Infiltration",
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"diagnosis": "FINDINGS: Bilateral perihilar interstitial and airspace opacities with central distribution. No pleural effusion. No pneumothorax. IMPRESSION: Interstitial pneumonia, favor atypical/viral etiology.",
|
| 134 |
+
"labels": "Infiltration|Consolidation",
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"diagnosis": "FINDINGS: Dense consolidation in the right upper lobe with air bronchograms and associated volume loss. Right tracheal shift. No cavitation. No effusion. IMPRESSION: Right upper lobe pneumonia with volume loss. Recommend follow-up to exclude underlying mass.",
|
| 138 |
+
"labels": "Consolidation|Atelectasis",
|
| 139 |
+
},
|
| 140 |
+
{
|
| 141 |
+
"diagnosis": "FINDINGS: Round pneumonia presenting as a spherical opacity in the left lower lobe. Surrounding ground-glass opacity. No effusion. No pneumothorax. IMPRESSION: Round pneumonia in the left lower lobe. Clinical correlation recommended.",
|
| 142 |
+
"labels": "Consolidation",
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"diagnosis": "FINDINGS: Cavitary lesion in the right upper lobe with thick irregular wall and surrounding consolidation. Air-fluid level present. No definite pleural effusion. IMPRESSION: Cavitary pneumonia, consider TB, fungal, or necrotizing bacterial infection.",
|
| 146 |
+
"labels": "Infiltration|Consolidation",
|
| 147 |
+
},
|
| 148 |
+
{
|
| 149 |
+
"diagnosis": "FINDINGS: Bilateral lower lobe consolidations with air bronchograms. Small bilateral pleural effusions. No pneumothorax. Cardiomegaly noted. IMPRESSION: Bilateral lower lobe pneumonias superimposed on cardiomegaly.",
|
| 150 |
+
"labels": "Consolidation|Infiltration|Cardiomegaly|Effusion",
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
"diagnosis": "FINDINGS: Segmental airspace opacity in the lingula with obscuration of the left heart border (silhouette sign). No effusion. No pneumothorax. IMPRESSION: Lingular pneumonia.",
|
| 154 |
+
"labels": "Consolidation|Infiltration",
|
| 155 |
+
},
|
| 156 |
+
|
| 157 |
+
# ── Atelectasis ──
|
| 158 |
+
{
|
| 159 |
+
"diagnosis": "FINDINGS: Linear opacity in the right lower lobe extending to the pleura. Minor fissure elevation. No pleural effusion. No pneumothorax. IMPRESSION: Platelike/segmental atelectasis in the right lower lobe.",
|
| 160 |
+
"labels": "Atelectasis",
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"diagnosis": "FINDINGS: Bandlike opacity in the left base with elevation of the left hemidiaphragm. Compensatory hyperinflation of the remaining lung. No effusion. IMPRESSION: Left basilar subsegmental atelectasis.",
|
| 164 |
+
"labels": "Atelectasis",
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
"diagnosis": "FINDINGS: Triangular opacity in the right base with the apex pointing toward the hilum. Right hemidiaphragm is elevated. No pleural effusion. IMPRESSION: Right lower lobe atelectasis.",
|
| 168 |
+
"labels": "Atelectasis",
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"diagnosis": "FINDINGS: Golden S sign visible in the right upper lobe with an S-shaped curve of the minor fissure. A central hilar mass is suspected underlying the lobar atelectasis. No effusion. IMPRESSION: Right upper lobe atelectasis. The Golden S sign suggests an underlying central mass. CT chest recommended.",
|
| 172 |
+
"labels": "Atelectasis|Mass",
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"diagnosis": "FINDINGS: Widespread platelike atelectasis in both lower lobes. Low lung volumes. Hemidiaphragms are elevated. No effusion or pneumothorax. IMPRESSION: Bilateral basilar platelike atelectasis due to hypoventilation.",
|
| 176 |
+
"labels": "Atelectasis",
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"diagnosis": "FINDINGS: Complete opacification of the left hemithorax with mediastinal shift to the left. Compensatory hyperinflation of the right lung. No pneumothorax. IMPRESSION: Complete left lung atelectasis. Underlying obstructing lesion suspected. Urgent CT and bronchoscopy recommended.",
|
| 180 |
+
"labels": "Atelectasis|Mass",
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"diagnosis": "FINDINGS: Rounded atelectasis in the right lower lobe presenting as a rounded opacity with comet-tail sign. Pleural thickening adjacent. No change from prior study. IMPRESSION: Rounded atelectasis, stable. No evidence of active disease.",
|
| 184 |
+
"labels": "Atelectasis|Pleural_Thickening",
|
| 185 |
+
},
|
| 186 |
+
{
|
| 187 |
+
"diagnosis": "FINDINGS: Discoid atelectasis in the right midlung. Minimal volume loss. No pleural effusion. Heart size normal. IMPRESSION: Discoid atelectasis. Otherwise unremarkable chest.",
|
| 188 |
+
"labels": "Atelectasis",
|
| 189 |
+
},
|
| 190 |
+
|
| 191 |
+
# ── Pleural Effusion ──
|
| 192 |
+
{
|
| 193 |
+
"diagnosis": "FINDINGS: Moderate right pleural effusion with blunting of the right costophrenic angle and a meniscus sign. Underlying compressive atelectasis. No pneumothorax. IMPRESSION: Moderate right pleural effusion with adjacent atelectasis.",
|
| 194 |
+
"labels": "Effusion|Atelectasis",
|
| 195 |
+
},
|
| 196 |
+
{
|
| 197 |
+
"diagnosis": "FINDINGS: Large left pleural effusion causing near-complete opacification of the left hemithorax with mediastinal shift to the right. No pneumothorax. IMPRESSION: Large left pleural effusion with mass effect. Thoracentesis recommended.",
|
| 198 |
+
"labels": "Effusion",
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"diagnosis": "FINDINGS: Bilateral small pleural effusions with blunting of both costophrenic angles. No layering on lateral decubitus. No pneumothorax. IMPRESSION: Small bilateral pleural effusions.",
|
| 202 |
+
"labels": "Effusion",
|
| 203 |
+
},
|
| 204 |
+
{
|
| 205 |
+
"diagnosis": "FINDINGS: Loculated right pleural effusion with biconvex opacity along the lateral chest wall extending into the fissure. No free-flowing component. No pneumothorax. IMPRESSION: Loculated right pleural effusion, consider empyema or hemothorax.",
|
| 206 |
+
"labels": "Effusion",
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
"diagnosis": "FINDINGS: Massive right pleural effusion with complete opacification of the right hemithorax and contralateral mediastinal shift. Left lung clear. No pneumothorax. IMPRESSION: Massive right pleural effusion. Therapeutic thoracentesis indicated.",
|
| 210 |
+
"labels": "Effusion",
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"diagnosis": "FINDINGS: Small right pleural effusion with intact meniscus sign. No pleural thickening. No loculation. Lungs are clear. IMPRESSION: Small right pleural effusion. Clinical correlation for etiology recommended.",
|
| 214 |
+
"labels": "Effusion",
|
| 215 |
+
},
|
| 216 |
+
{
|
| 217 |
+
"diagnosis": "FINDINGS: Bilateral pleural effusions with associated basal atelectasis. No pneumothorax. Cardiomegaly noted. Pulmonary vascular congestion present. IMPRESSION: Bilateral pleural effusions in the setting of congestive heart failure.",
|
| 218 |
+
"labels": "Effusion|Cardiomegaly|Atelectasis",
|
| 219 |
+
},
|
| 220 |
+
|
| 221 |
+
# ── Pneumothorax ──
|
| 222 |
+
{
|
| 223 |
+
"diagnosis": "FINDINGS: Right apical pneumothorax with visceral pleural line visible approximately 2 cm from the chest wall. No mediastinal shift. The underlying lung is clear. No effusion. IMPRESSION: Small right apical pneumothorax.",
|
| 224 |
+
"labels": "Pneumothorax",
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
"diagnosis": "FINDINGS: Moderate left pneumothorax with the lung edge seen at 3 cm from the lateral chest wall. Mild mediastinal shift to the right. No pleural effusion. IMPRESSION: Moderate left pneumothorax with early tension physiology.",
|
| 228 |
+
"labels": "Pneumothorax",
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"diagnosis": "FINDINGS: Large right pneumothorax with complete collapse of the right lung (lung appears as a dense hilar mass). Significant mediastinal shift to the left. Depressed right hemidiaphragm. Deep sulcus sign present. IMPRESSION: Large tension pneumothorax on the right. Requires immediate decompression.",
|
| 232 |
+
"labels": "Pneumothorax",
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"diagnosis": "FINDINGS: Small left apical pneumothorax. No mediastinal shift. No pleural effusion. Remainder of lung is clear. Heart size normal. IMPRESSION: Small spontaneous pneumothorax.",
|
| 236 |
+
"labels": "Pneumothorax",
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
"diagnosis": "FINDINGS: Hydropneumothorax on the left with visible air-fluid level extending across the hemithorax. Partially collapsed left lung. Mediastinal shift to the right. IMPRESSION: Left hydropneumothorax. Consider empyema or bronchopleural fistula.",
|
| 240 |
+
"labels": "Pneumothorax|Effusion",
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"diagnosis": "FINDINGS: Tension pneumothorax on the right with deep sulcus sign, mediastinal shift to the left, and flattening of the right heart border. Complete right lung collapse. IMPRESSION: Large right tension pneumothorax, life-threatening. STAT decompression indicated.",
|
| 244 |
+
"labels": "Pneumothorax",
|
| 245 |
+
},
|
| 246 |
+
{
|
| 247 |
+
"diagnosis": "FINDINGS: Tiny right apical pneumothorax barely visible. Visceral pleural line seen only on inspiratory film. Less than 1 cm from chest wall. No mediastinal shift. IMPRESSION: Very small right apical pneumothorax, likely will resolve spontaneously.",
|
| 248 |
+
"labels": "Pneumothorax",
|
| 249 |
+
},
|
| 250 |
+
|
| 251 |
+
# ── COPD / Emphysema ──
|
| 252 |
+
{
|
| 253 |
+
"diagnosis": "FINDINGS: Hyperinflated lungs with flattened hemidiaphragms. Increased AP chest diameter. Bullous changes at the apices. Heart size is normal. No pneumothorax or effusion. IMPRESSION: Chronic obstructive pulmonary disease with emphysematous changes.",
|
| 254 |
+
"labels": "Emphysema",
|
| 255 |
+
},
|
| 256 |
+
{
|
| 257 |
+
"diagnosis": "FINDINGS: Severe hyperinflation with flattened and depressed hemidiaphragms. Widened intercostal spaces. Increased retrosternal clear space. Small heart. No focal consolidation. No pneumothorax. IMPRESSION: Severe emphysema. No acute infiltrate.",
|
| 258 |
+
"labels": "Emphysema",
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
"diagnosis": "FINDINGS: Hyperlucent lungs with attenuation of peripheral vascular markings. Large central pulmonary arteries. Flattened hemidiaphragms. No bullae. No pneumothorax. IMPRESSION: Emphysema with pulmonary hypertension signs. Cor pulmonale suspected.",
|
| 262 |
+
"labels": "Emphysema",
|
| 263 |
+
},
|
| 264 |
+
{
|
| 265 |
+
"diagnosis": "FINDINGS: Severe bilateral bullous emphysema. Large thin-walled bullae occupy more than one-third of both hemithoraces. No pneumothorax. No pleural effusion. Cardiac silhouette is vertically oriented and small. IMPRESSION: Severe bilateral bullous emphysema.",
|
| 266 |
+
"labels": "Emphysema",
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
"diagnosis": "FINDINGS: Moderate hyperinflation of the lungs with flattened hemidiaphragms. Subtle reticular opacities in the lung bases. Heart size normal. No effusion or pneumothorax. IMPRESSION: COPD with mild interstitial changes.",
|
| 270 |
+
"labels": "Emphysema|Fibrosis",
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"diagnosis": "FINDINGS: Hyperexpanded lungs with increased retrosternal airspace. Mild flattening of the diaphragms. No focal consolidation. Heart size is normal. No pneumothorax. IMPRESSION: Mild hyperinflation, consistent with early COPD.",
|
| 274 |
+
"labels": "Emphysema",
|
| 275 |
+
},
|
| 276 |
+
|
| 277 |
+
# ── Nodules and Masses ──
|
| 278 |
+
{
|
| 279 |
+
"diagnosis": "FINDINGS: Solitary pulmonary nodule in the right upper lobe measuring approximately 1.5 cm. Margins are smooth. No calcification. No associated adenopathy. No pleural effusion. IMPRESSION: Solitary pulmonary nodule. Recommend CT chest for further characterization.",
|
| 280 |
+
"labels": "Nodule|Mass",
|
| 281 |
+
},
|
| 282 |
+
{
|
| 283 |
+
"diagnosis": "FINDINGS: Spiculated mass in the left upper lobe measuring 3.2 x 2.8 cm. Associated pleural tail. No cavitation. No calcification. No hilar or mediastinal adenopathy. No pleural effusion. IMPRESSION: Spiculated left upper lobe mass suspicious for primary lung malignancy. CT and tissue sampling recommended.",
|
| 284 |
+
"labels": "Mass|Nodule",
|
| 285 |
+
},
|
| 286 |
+
{
|
| 287 |
+
"diagnosis": "FINDINGS: Multiple bilateral pulmonary nodules of varying sizes, ranging from 0.5 to 2.0 cm. Random distribution. No cavitation. No calcification. Small right pleural effusion. No pneumothorax. IMPRESSION: Numerous bilateral pulmonary nodules consistent with metastatic disease.",
|
| 288 |
+
"labels": "Nodule|Mass|Effusion",
|
| 289 |
+
},
|
| 290 |
+
{
|
| 291 |
+
"diagnosis": "FINDINGS: Cavitating mass in the right upper lobe with thick, irregular walls measuring up to 1.5 cm in thickness. Air-fluid level present. Surrounding ground-glass opacity. No effusion. IMPRESSION: Cavitating lung mass, differential includes primary lung carcinoma, abscess, or fungal infection.",
|
| 292 |
+
"labels": "Mass|Nodule",
|
| 293 |
+
},
|
| 294 |
+
{
|
| 295 |
+
"diagnosis": "FINDINGS: Pancoast tumor in the right apex with associated apical cap thickening and destruction of the posterior right first and second ribs. No mediastinal widening. No effusion. IMPRESSION: Right apical mass (Pancoast tumor) with chest wall invasion. Urgent CT and biopsy recommended.",
|
| 296 |
+
"labels": "Mass",
|
| 297 |
+
},
|
| 298 |
+
{
|
| 299 |
+
"diagnosis": "FINDINGS: Well-defined, smoothly marginated nodule in the left lower lobe with popcorn calcification. No growth compared to prior study. No effusion. No pneumothorax. IMPRESSION: Hamartoma with characteristic popcorn calcification. Benign appearance.",
|
| 300 |
+
"labels": "Nodule",
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"diagnosis": "FINDINGS: Subsolid nodule with ground-glass and solid components (part-solid nodule) in the right middle lobe. About 1.2 cm. No calcification. No pleural effusion. IMPRESSION: Part-solid nodule suspicious for adenocarcinoma spectrum. CT follow-up recommended.",
|
| 304 |
+
"labels": "Nodule",
|
| 305 |
+
},
|
| 306 |
+
{
|
| 307 |
+
"diagnosis": "FINDINGS: Numerous small, well-defined nodules in a perilymphatic distribution with fissural nodularity and right paratracheal adenopathy. No pleural effusion. No pneumothorax. IMPRESSION: Perilymphatic nodules and mediastinal adenopathy, consider sarcoidosis or lymphangitic carcinomatosis.",
|
| 308 |
+
"labels": "Nodule",
|
| 309 |
+
},
|
| 310 |
+
{
|
| 311 |
+
"diagnosis": "FINDINGS: Solitary pulmonary nodule in the right lower lobe measuring 8 mm. Contains central calcification. No spiculation. No adenopathy. No effusion. IMPRESSION: Calcified granuloma. Benign appearance, no further action required.",
|
| 312 |
+
"labels": "Nodule",
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
"diagnosis": "FINDINGS: Large 6 cm mass in the left lower lobe with irregular borders and central necrosis. Left hilar adenopathy present. No pleural effusion. No pneumothorax. IMPRESSION: Large left lower lobe mass with hilar adenopathy, highly suspicious for malignancy.",
|
| 316 |
+
"labels": "Mass|Nodule",
|
| 317 |
+
},
|
| 318 |
+
{
|
| 319 |
+
"diagnosis": "FINDINGS: Anterior mediastinal mass with lobulated contours and no calcification. Trachea is midline. No pleural or pericardial effusion. Lungs are clear. IMPRESSION: Anterior mediastinal mass, differential includes thymoma, lymphoma, or germ cell tumor. CT with contrast recommended.",
|
| 320 |
+
"labels": "Mass",
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"diagnosis": "FINDINGS: Middle mediastinal mass causing splaying of the carina. No calcification. No pleural effusion. Lungs are clear. IMPRESSION: Subcarinal mediastinal mass/massive lymphadenopathy. CT chest recommended for further evaluation.",
|
| 324 |
+
"labels": "Mass",
|
| 325 |
+
},
|
| 326 |
+
|
| 327 |
+
# ── Interstitial Lung Disease / Fibrosis ──
|
| 328 |
+
{
|
| 329 |
+
"diagnosis": "FINDINGS: Diffuse bilateral reticular opacities with honeycombing in the lung bases. Traction bronchiectasis present. No pneumothorax. No pleural effusion. Normal heart size. IMPRESSION: Usual interstitial pneumonia (UIP) pattern with honeycombing and traction bronchiectasis. Consistent with idiopathic pulmonary fibrosis.",
|
| 330 |
+
"labels": "Fibrosis",
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"diagnosis": "FINDINGS: Bilateral ground-glass opacities in the lower lobes with reticular superimposed opacities. Minimal honeycombing. No pleural effusion. No pneumothorax. IMPRESSION: Nonspecific interstitial pneumonia (NSIP) pattern. Clinical correlation recommended.",
|
| 334 |
+
"labels": "Fibrosis|Infiltration",
|
| 335 |
+
},
|
| 336 |
+
{
|
| 337 |
+
"diagnosis": "FINDINGS: Extensive bilateral interstitial opacities with a perilymphatic distribution. Septal thickening and subpleural nodules. Bilateral hilar and right paratracheal lymphadenopathy. No effusion. IMPRESSION: Pulmonary sarcoidosis with bilateral hilar adenopathy and interstitial lung disease (stage II sarcoidosis).",
|
| 338 |
+
"labels": "Fibrosis|Nodule",
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
"diagnosis": "FINDINGS: Bilateral upper lobe predominant fibrotic changes with volume loss, hilar retraction, and architectural distortion. Traction bronchiectasis. No pneumothorax. IMPRESSION: Chronic upper lobe fibrotic changes, consider post-TB sequelae or radiation fibrosis.",
|
| 342 |
+
"labels": "Fibrosis",
|
| 343 |
+
},
|
| 344 |
+
{
|
| 345 |
+
"diagnosis": "FINDINGS: Diffuse bilateral ground-glass opacities with superimposed reticular opacities and traction bronchiectasis in the lung bases. No honeycombing. No effusion. IMPRESSION: Probable interstitial lung disease. HRCT recommended for further characterization.",
|
| 346 |
+
"labels": "Fibrosis|Infiltration",
|
| 347 |
+
},
|
| 348 |
+
{
|
| 349 |
+
"diagnosis": "FINDINGS: Bilateral apical pleural thickening with subpleural fibrotic bands. Upper lobe volume loss with upward hilar retraction. No cavitation. No pneumothorax. IMPRESSION: Chronic apical fibrosis with pleural thickening, likely post-inflammatory.",
|
| 350 |
+
"labels": "Fibrosis|Pleural_Thickening",
|
| 351 |
+
},
|
| 352 |
+
{
|
| 353 |
+
"diagnosis": "FINDINGS: Crazy-paving pattern with scattered ground-glass opacities superimposed on interlobular septal thickening in the bilateral lower lobes. No pleural effusion. No pneumothorax. IMPRESSION: Crazy-paving pattern. Differential includes alveolar proteinosis, lipoid pneumonia, or cardiogenic pulmonary edema. Clinical correlation recommended.",
|
| 354 |
+
"labels": "Infiltration|Fibrosis",
|
| 355 |
+
},
|
| 356 |
+
{
|
| 357 |
+
"diagnosis": "FINDINGS: Bilateral reticulonodular opacities in a mid-to-upper lung distribution. Eggshell calcifications in bilateral hilar lymph nodes. No pleural effusion. IMPRESSION: Silicosis with eggshell calcification of hilar lymph nodes and reticulonodular interstitial lung disease.",
|
| 358 |
+
"labels": "Fibrosis|Nodule",
|
| 359 |
+
},
|
| 360 |
+
{
|
| 361 |
+
"diagnosis": "FINDINGS: Bilateral pleural plaques with calcification along the diaphragmatic pleura and lateral chest wall. No pleural effusion. Lungs are otherwise clear. No pneumothorax. IMPRESSION: Bilateral pleural plaques in a patient with asbestos exposure history. No evidence of asbestosis or mesothelioma.",
|
| 362 |
+
"labels": "Pleural_Thickening",
|
| 363 |
+
},
|
| 364 |
+
{
|
| 365 |
+
"diagnosis": "FINDINGS: Diffuse bilateral fine reticulonodular opacities predominantly in the lower lobes. No honeycombing. No pleural effusion. Normal heart size. IMPRESSION: Early interstitial lung disease, consider connective tissue disease-related ILD or hypersensitivity pneumonitis.",
|
| 366 |
+
"labels": "Fibrosis|Nodule",
|
| 367 |
+
},
|
| 368 |
+
{
|
| 369 |
+
"diagnosis": "FINDINGS: Lymphangitic carcinomatosis presenting with unilateral right-sided septal thickening and peribronchial cuffing. Right hilar adenopathy. Small right pleural effusion. No pneumothorax. IMPRESSION: Right-sided lymphangitic carcinomatosis, consistent with known primary malignancy.",
|
| 370 |
+
"labels": "Infiltration|Effusion|Nodule",
|
| 371 |
+
},
|
| 372 |
+
|
| 373 |
+
# ── Pulmonary Edema ──
|
| 374 |
+
{
|
| 375 |
+
"diagnosis": "FINDINGS: Bilateral perihilar airspace opacities with a butterfly-wing pattern. Upper lobe pulmonary vascular redistribution. Kerley B lines at the lung bases. Cardiomegaly. Small bilateral pleural effusions. No pneumothorax. IMPRESSION: Acute pulmonary edema due to congestive heart failure.",
|
| 376 |
+
"labels": "Edema|Cardiomegaly|Effusion",
|
| 377 |
+
},
|
| 378 |
+
{
|
| 379 |
+
"diagnosis": "FINDINGS: Diffuse bilateral airspace opacities that are more confluent centrally with peripheral sparing. No cardiomegaly. No pleural effusion. Normal heart size. No pneumothorax. IMPRESSION: Noncardiogenic pulmonary edema (ARDS pattern). Clinical correlation needed.",
|
| 380 |
+
"labels": "Edema|Infiltration",
|
| 381 |
+
},
|
| 382 |
+
{
|
| 383 |
+
"diagnosis": "FINDINGS: Mild interstitial pulmonary edema with peribronchial cuffing, indistinct vascular margins, and Kerley A and B lines. Cardiomegaly is mild. No pleural effusion. No pneumothorax. IMPRESSION: Mild interstitial pulmonary edema, early congestive heart failure.",
|
| 384 |
+
"labels": "Edema|Cardiomegaly",
|
| 385 |
+
},
|
| 386 |
+
{
|
| 387 |
+
"diagnosis": "FINDINGS: Asymmetric left-sided pulmonary edema with a perihilar distribution. Patchy airspace opacities in the left upper and lower lobes. No pleural effusion. Normal heart size. No pneumothorax. IMPRESSION: Asymmetric pulmonary edema. Consider acute mitral regurgitation or localized lung pathology.",
|
| 388 |
+
"labels": "Edema|Infiltration",
|
| 389 |
+
},
|
| 390 |
+
|
| 391 |
+
# ── Tuberculosis ──
|
| 392 |
+
{
|
| 393 |
+
"diagnosis": "FINDINGS: Right apical fibronodular opacities with associated volume loss and upward hilar retraction. Small calcified granuloma in the right apex. No cavitation. No pleural effusion. No pneumothorax. IMPRESSION: Right apical fibronodular changes consistent with prior granulomatous disease, likely old TB. No evidence of active disease.",
|
| 394 |
+
"labels": "Fibrosis|Nodule",
|
| 395 |
+
},
|
| 396 |
+
{
|
| 397 |
+
"diagnosis": "FINDINGS: Cavitary lesion in the right upper lobe with surrounding airspace disease. Tree-in-bud opacities in the right upper lobe. Right paratracheal adenopathy. No pleural effusion. IMPRESSION: Right upper lobe cavitary lesion with tree-in-bud opacities suspicious for active pulmonary tuberculosis. Sputum AFB recommended.",
|
| 398 |
+
"labels": "Infiltration|Nodule",
|
| 399 |
+
},
|
| 400 |
+
{
|
| 401 |
+
"diagnosis": "FINDINGS: Military pattern with innumerable tiny 1-2 mm nodules diffusely throughout both lungs. No consolidation. No pleural effusion. Heart size normal. No pneumothorax. IMPRESSION: Military nodules, highly suspicious for miliary tuberculosis. Urgent clinical correlation and AFB cultures recommended.",
|
| 402 |
+
"labels": "Nodule",
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
"diagnosis": "FINDINGS: Left apical pleural thickening with an associated fibrotic band extending to the hilum. Calcified left hilar lymph node. Surrounding parenchyma shows traction bronchiectasis. No effusion. IMPRESSION: Old tuberculosis with apical pleural thickening and fibrotic scarring. Stable appearance.",
|
| 406 |
+
"labels": "Fibrosis|Pleural_Thickening",
|
| 407 |
+
},
|
| 408 |
+
{
|
| 409 |
+
"diagnosis": "FINDINGS: Bilateral upper lobe fibrocavitary disease with thick-walled cavities and surrounding retraction. Elevated hila. Compensatory hyperinflation of lower lobes. No pneumothorax. Minimal pleural thickening. IMPRESSION: Chronic fibrocavitary TB with bilateral upper lobe involvement. Active disease cannot be excluded.",
|
| 410 |
+
"labels": "Fibrosis|Infiltration",
|
| 411 |
+
},
|
| 412 |
+
|
| 413 |
+
# ── Bronchiectasis ──
|
| 414 |
+
{
|
| 415 |
+
"diagnosis": "FINDINGS: Dilated, thickened bronchi seen end-on as tram-track opacities in the bilateral lower lobes. Bronchial wall thickening. Some mucus plugging. No consolidation. No pleural effusion. No pneumothorax. IMPRESSION: Bilateral lower lobe bronchiectasis with peribronchial thickening and mucus plugging.",
|
| 416 |
+
"labels": "Infiltration",
|
| 417 |
+
},
|
| 418 |
+
{
|
| 419 |
+
"diagnosis": "FINDINGS: Severe cystic bronchiectasis in both lower lobes with thin-walled cysts and air-fluid levels. Signet-ring sign present. Scattered tree-in-bud opacities. No pneumothorax. No pleural effusion. IMPRESSION: Cystic bronchiectasis with superinfection. Clinical correlation and sputum culture recommended.",
|
| 420 |
+
"labels": "Infiltration",
|
| 421 |
+
},
|
| 422 |
+
{
|
| 423 |
+
"diagnosis": "FINDINGS: Tram-track and ring-shadow opacities in the right middle lobe and lingula. Volume loss in the right middle lobe. No pleural effusion. No pneumothorax. IMPRESSION: Right middle lobe and lingular bronchiectasis with volume loss.",
|
| 424 |
+
"labels": "Infiltration|Atelectasis",
|
| 425 |
+
},
|
| 426 |
+
{
|
| 427 |
+
"diagnosis": "FINDINGS: Central bronchiectasis with dilated, mucus-filled bronchi appearing as glove-finger opacities radiating from the hila. Tree-in-bud opacities in the surrounding parenchyma. No effusion. No pneumothorax. IMPRESSION: Central bronchiectasis with mucoid impaction. Consider allergic bronchopulmonary aspergillosis (ABPA) if asthmatic.",
|
| 428 |
+
"labels": "Infiltration",
|
| 429 |
+
},
|
| 430 |
+
|
| 431 |
+
# ── Rib Fractures / Trauma ──
|
| 432 |
+
{
|
| 433 |
+
"diagnosis": "FINDINGS: Minimally displaced fracture of the right lateral 7th rib. Small associated right pleural effusion. No pneumothorax. Lungs are otherwise clear. IMPRESSION: Right 7th rib fracture with small adjacent pleural effusion.",
|
| 434 |
+
"labels": "Effusion",
|
| 435 |
+
},
|
| 436 |
+
{
|
| 437 |
+
"diagnosis": "FINDINGS: Multiple left-sided rib fractures involving ribs 4-8 with a flail segment. Large left hemothorax with nearly complete opacification of the left hemithorax. Mediastinal shift to the right. No pneumothorax. IMPRESSION: Left flail chest with massive hemothorax. Cardiac contusion may be present.",
|
| 438 |
+
"labels": "Effusion",
|
| 439 |
+
},
|
| 440 |
+
{
|
| 441 |
+
"diagnosis": "FINDINGS: Fracture of the left clavicle with inferior displacement. No pneumothorax. No hemothorax. Lungs are clear. Heart and mediastinum normal. IMPRESSION: Isolated left clavicular fracture. No thoracic injury.",
|
| 442 |
+
"labels": "",
|
| 443 |
+
},
|
| 444 |
+
{
|
| 445 |
+
"diagnosis": "FINDINGS: Multiple bilateral old rib fractures with callus formation and cortical irregularity. No acute fracture line identified. No pneumothorax. No pleural effusion. Lungs clear. IMPRESSION: Multiple old healed rib fractures. No acute traumatic findings.",
|
| 446 |
+
"labels": "",
|
| 447 |
+
},
|
| 448 |
+
{
|
| 449 |
+
"diagnosis": "FINDINGS: Superior sulcus opacity on the right with thickened pleura and erosion of the posterior right first rib. Correlate clinically for Pancoast syndrome. No effusion. No pneumothorax. IMPRESSION: Right apical mass with rib destruction concerning for Pancoast tumor.",
|
| 450 |
+
"labels": "Mass",
|
| 451 |
+
},
|
| 452 |
+
{
|
| 453 |
+
"diagnosis": "FINDINGS: Sternal fracture with mild displacement noted on lateral view. No pneumothorax. No hemothorax. No mediastinal widening. Heart size normal. IMPRESSION: Sternal fracture without underlying cardiac or aortic injury.",
|
| 454 |
+
"labels": "",
|
| 455 |
+
},
|
| 456 |
+
{
|
| 457 |
+
"diagnosis": "FINDINGS: Wide mediastinum measuring >8 cm on AP view. Loss of aortic knob contour. Left apical cap. Left pleural effusion. No pneumothorax. IMPRESSION: Traumatic aortic injury suggested by mediastinal widening, apical cap, and left pleural effusion. Urgent CT aortogram and surgical consultation required.",
|
| 458 |
+
"labels": "Effusion",
|
| 459 |
+
},
|
| 460 |
+
|
| 461 |
+
# ── Hiatal Hernia ──
|
| 462 |
+
{
|
| 463 |
+
"diagnosis": "FINDINGS: Large retrocardiac air-fluid level behind the heart. Visible herniated stomach contents above the diaphragm. Mild compressive atelectasis of the left lower lobe. Heart size normal. No pneumothorax. IMPRESSION: Large hiatal hernia with stomach herniated into the chest. No acute obstructive findings.",
|
| 464 |
+
"labels": "Hernia",
|
| 465 |
+
},
|
| 466 |
+
{
|
| 467 |
+
"diagnosis": "FINDINGS: Moderate hiatal hernia seen on the lateral view as a retrocardiac opacity with air-fluid level. The hernia contains both stomach and colon. No evidence of obstruction. No pleural effusion. IMPRESSION: Large hiatal hernia containing stomach and colon, likely chronic.",
|
| 468 |
+
"labels": "Hernia",
|
| 469 |
+
},
|
| 470 |
+
{
|
| 471 |
+
"diagnosis": "FINDINGS: Small hiatal hernia noted incidentally. No air-fluid level. No associated atelectasis. Normal cardiomediastinal silhouette. IMPRESSION: Small hiatal hernia, otherwise normal chest.",
|
| 472 |
+
"labels": "Hernia",
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
"diagnosis": "FINDINGS: Large hiatal hernia with a mixed gas and soft tissue density in the retrocardiac region. The herniated stomach shows evidence of volvulus with two air-fluid levels at different heights. No pneumothorax. No pleural effusion. IMPRESSION: Large hiatal hernia with gastric volvulus. Urgent surgical evaluation recommended.",
|
| 476 |
+
"labels": "Hernia",
|
| 477 |
+
},
|
| 478 |
+
|
| 479 |
+
# ── Pneumoperitoneum / Free Air ──
|
| 480 |
+
{
|
| 481 |
+
"diagnosis": "FINDINGS: Free air under the right hemidiaphragm on upright PA view. Air outlining the liver. No pneumothorax. Lungs are clear. Heart size normal. IMPRESSION: Pneumoperitoneum suggesting hollow viscus perforation. Urgent surgical evaluation recommended.",
|
| 482 |
+
"labels": "",
|
| 483 |
+
},
|
| 484 |
+
{
|
| 485 |
+
"diagnosis": "FINDINGS: Rigler sign visible with air outlining both sides of the bowel wall. Free air under both hemidiaphragms. Visible falciform ligament. No pneumothorax. IMPRESSION: Massive pneumoperitoneum. Emergency surgical consultation required.",
|
| 486 |
+
"labels": "",
|
| 487 |
+
},
|
| 488 |
+
{
|
| 489 |
+
"diagnosis": "FINDINGS: Football sign in the supine AP view with large oval lucency over the upper abdomen. Air outlining the peritoneal cavity. No pneumothorax. IMPRESSION: Large pneumoperitoneum (football sign). Suspect perforated viscus.",
|
| 490 |
+
"labels": "",
|
| 491 |
+
},
|
| 492 |
+
|
| 493 |
+
# ── Aortic Aneurysm / Dissection ──
|
| 494 |
+
{
|
| 495 |
+
"diagnosis": "FINDINGS: Prominent thoracic aortic knob with calcification. Mediastinal width is at the upper limits of normal. No definite widening. Lungs are clear. IMPRESSION: Tortuous calcified aorta without definite aneurysm.",
|
| 496 |
+
"labels": "",
|
| 497 |
+
},
|
| 498 |
+
{
|
| 499 |
+
"diagnosis": "FINDINGS: Wide mediastinum with loss of the aortic knob contour. Tracheal deviation to the right. Left pleural effusion. No pneumothorax. Heart size normal. IMPRESSION: Wide mediastinum concerning for aortic dissection. CT aortogram recommended urgently.",
|
| 500 |
+
"labels": "Effusion",
|
| 501 |
+
},
|
| 502 |
+
{
|
| 503 |
+
"diagnosis": "FINDINGS: Prominent descending thoracic aorta with calcified walls. Measured 4.5 cm in diameter. No dissection flap visible on X-ray. No pleural effusion. Lungs clear. IMPRESSION: Descending thoracic aortic aneurysm measuring 4.5 cm. CT with contrast recommended for further characterization.",
|
| 504 |
+
"labels": "",
|
| 505 |
+
},
|
| 506 |
+
{
|
| 507 |
+
"diagnosis": "FINDINGS: Calcified aortic arch aneurysm projecting to the right of the trachea. No definite dissection. No pleural effusion. No pneumothorax. IMPRESSION: Aortic arch aneurysm. Echocardiogram or CT for further evaluation recommended.",
|
| 508 |
+
"labels": "",
|
| 509 |
+
},
|
| 510 |
+
|
| 511 |
+
# ── Pericardial Effusion ──
|
| 512 |
+
{
|
| 513 |
+
"diagnosis": "FINDINGS: Globular enlargement of the cardiac silhouette with a water-bottle configuration. Clear sharp borders. Lungs are clear. No pleural effusion. Pulmonary vascularity is normal. No pneumothorax. IMPRESSION: Large pericardial effusion giving a water-bottle heart appearance. Echocardiogram recommended.",
|
| 514 |
+
"labels": "",
|
| 515 |
+
},
|
| 516 |
+
{
|
| 517 |
+
"diagnosis": "FINDINGS: Mild enlargement of the cardiac silhouette. Epicardial fat pad sign visible with a lucent line separating the heart from the pericardium. Lungs clear. No pleural effusion. IMPRESSION: Small to moderate pericardial effusion. Echocardiogram for confirmation.",
|
| 518 |
+
"labels": "",
|
| 519 |
+
},
|
| 520 |
+
{
|
| 521 |
+
"diagnosis": "FINDINGS: Massive globular cardiomegaly with clear lungs. No pulmonary vascular congestion. No pleural effusion. No pneumothorax. IMPRESSION: Large pericardial effusion. Rule out pericardial tamponade. Urgent echocardiogram indicated.",
|
| 522 |
+
"labels": "",
|
| 523 |
+
},
|
| 524 |
+
|
| 525 |
+
# ── Pulmonary Embolism ──
|
| 526 |
+
{
|
| 527 |
+
"diagnosis": "FINDINGS: Hampton's hump: wedge-shaped pleural-based opacity in the right costophrenic angle. Small right pleural effusion. No pneumothorax. Heart size normal. IMPRESSION: Wedge-shaped opacity consistent with pulmonary infarct, likely secondary to pulmonary embolism. CT pulmonary angiogram recommended.",
|
| 528 |
+
"labels": "Effusion|Infiltration",
|
| 529 |
+
},
|
| 530 |
+
{
|
| 531 |
+
"diagnosis": "FINDINGS: Westermark sign: oligemia of the left lung with decreased vascular markings. Prominent central pulmonary artery on the left (Fleischner sign). No pleural effusion. No pneumothorax. IMPRESSION: Westermark sign suggesting pulmonary embolism in the left pulmonary artery. Urgent CT pulmonary angiography recommended.",
|
| 532 |
+
"labels": "",
|
| 533 |
+
},
|
| 534 |
+
{
|
| 535 |
+
"diagnosis": "FINDINGS: Enlarged right descending pulmonary artery (Palla sign). Right lower lobe opacity consistent with infarct. Small right pleural effusion. No pneumothorax. IMPRESSION: Right pulmonary artery enlargement with adjacent infarct, concerning for pulmonary embolism. CTPA recommended.",
|
| 536 |
+
"labels": "Effusion|Infiltration",
|
| 537 |
+
},
|
| 538 |
+
|
| 539 |
+
# ── Pediatric / Congenital ──
|
| 540 |
+
{
|
| 541 |
+
"diagnosis": "FINDINGS: Thymic sail sign present with a triangular soft tissue density projecting from the right superior mediastinum. Lungs clear. Heart size normal. No pneumothorax. IMPRESSION: Normal thymus with sail sign. No abnormality in this pediatric chest.",
|
| 542 |
+
"labels": "No Finding",
|
| 543 |
+
},
|
| 544 |
+
{
|
| 545 |
+
"diagnosis": "FINDINGS: Scimitar sign: curved tubular opacity in the right lower lobe coursing toward the right cardiophrenic angle. Right lung is hypoplastic. Mediastinal shift to the right. Heart is dextroposed. IMPRESSION: Scimitar syndrome (hypogenetic right lung syndrome with partial anomalous pulmonary venous return).",
|
| 546 |
+
"labels": "",
|
| 547 |
+
},
|
| 548 |
+
{
|
| 549 |
+
"diagnosis": "FINDINGS: Boot-shaped heart with upturned cardiac apex, prominent right ventricle, and concave pulmonary artery segment (coeur en sabot). Decreased pulmonary vascularity. No pleural effusion. IMPRESSION: Tetralogy of Fallot with classic boot-shaped heart. Surgical evaluation recommended.",
|
| 550 |
+
"labels": "",
|
| 551 |
+
},
|
| 552 |
+
{
|
| 553 |
+
"diagnosis": "FINDINGS: Egg-on-side cardiac silhouette with narrow vascular pedicle. Increased pulmonary vascularity. No pleural effusion. No pneumothorax. IMPRESSION: Transposition of the great arteries with egg-on-side heart. Neonatal cardiology evaluation required.",
|
| 554 |
+
"labels": "",
|
| 555 |
+
},
|
| 556 |
+
{
|
| 557 |
+
"diagnosis": "FINDINGS: Figure-of-3 sign in the aortic knob with rib notching of the posterior inferior aspects of ribs 3-8 bilaterally. Heart size normal. Lungs clear. No pleural effusion. IMPRESSION: Coarctation of the aorta with classic figure-of-3 sign and rib notching due to collateral circulation.",
|
| 558 |
+
"labels": "",
|
| 559 |
+
},
|
| 560 |
+
{
|
| 561 |
+
"diagnosis": "FINDINGS: Dilated azygos vein seen as a comma-shaped density at the right tracheobronchial angle. No mediastinal mass. Lungs are clear. Heart size normal. IMPRESSION: Prominent azygos vein, likely due to azygos continuation of the IVC or congenital anomaly.",
|
| 562 |
+
"labels": "",
|
| 563 |
+
},
|
| 564 |
+
|
| 565 |
+
# ── Pneumoconiosis ──
|
| 566 |
+
{
|
| 567 |
+
"diagnosis": "FINDINGS: Small rounded nodular opacities (p, q, r type) in the upper and mid lung zones bilaterally. Eggshell calcifications of hilar lymph nodes. No pleural plaques. No pneumothorax. No pleural effusion. IMPRESSION: Simple silicosis with eggshell calcification of hilar nodes.",
|
| 568 |
+
"labels": "Nodule",
|
| 569 |
+
},
|
| 570 |
+
{
|
| 571 |
+
"diagnosis": "FINDINGS: Bilateral upper lobe predominantly large opacities (progressive massive fibrosis) with surrounding emphysema. Hilar retraction and architectural distortion. Eggshell calcification of hilar nodes. No pneumothorax. IMPRESSION: Complicated silicosis with progressive massive fibrosis (PMF).",
|
| 572 |
+
"labels": "Fibrosis|Mass",
|
| 573 |
+
},
|
| 574 |
+
{
|
| 575 |
+
"diagnosis": "FINDINGS: Bilateral diaphragmatic and lateral pleural plaques with calcification. No pleural effusion. Lungs are clear. No pneumothorax. Heart size normal. IMPRESSION: Bilateral pleural plaques due to asbestos exposure. No evidence of asbestosis or mesothelioma at this time.",
|
| 576 |
+
"labels": "Pleural_Thickening",
|
| 577 |
+
},
|
| 578 |
+
{
|
| 579 |
+
"diagnosis": "FINDINGS: Bilateral lower lobe interstitial fibrosis with subpleural lines and honeycombing. Bilateral calcified pleural plaques. No pleural effusion. No pneumothorax. IMPRESSION: Asbestosis with parenchymal fibrosis and bilateral pleural plaques.",
|
| 580 |
+
"labels": "Fibrosis|Pleural_Thickening",
|
| 581 |
+
},
|
| 582 |
+
{
|
| 583 |
+
"diagnosis": "FINDINGS: Diffuse bilateral fine nodular opacities in a mid-to-upper lung zone predominance. Minimal hilar adenopathy. No pleural involvement. No pneumothorax. IMPRESSION: Simple coal workers pneumoconiosis (CWP) with upper lobe predominant nodular opacities.",
|
| 584 |
+
"labels": "Nodule",
|
| 585 |
+
},
|
| 586 |
+
|
| 587 |
+
# ── Rare/Obscure Conditions ──
|
| 588 |
+
{
|
| 589 |
+
"diagnosis": "FINDINGS: Extensive bilateral ground-glass opacities with interlobular septal thickening (crazy-paving pattern) in a geographic distribution. No pleural effusion. No pneumothorax. IMPRESSION: Crazy-paving pattern, suspicious for pulmonary alveolar proteinosis. BAL and HRCT recommended.",
|
| 590 |
+
"labels": "Infiltration",
|
| 591 |
+
},
|
| 592 |
+
{
|
| 593 |
+
"diagnosis": "FINDINGS: Bilateral diffuse micronodular opacities with a mid-to-upper lung predominance. Multiple thin-walled cysts of varying sizes. No pneumothorax. No pleural effusion. IMPRESSION: Langerhans cell histiocytosis (LCH) with characteristic cysts and nodules in a smoker. HRCT for confirmation.",
|
| 594 |
+
"labels": "Nodule",
|
| 595 |
+
},
|
| 596 |
+
{
|
| 597 |
+
"diagnosis": "FINDINGS: Bilateral large thin-walled cysts with normal intervening lung parenchyma. Predominantly lower lobe distribution. No pneumothorax. No pleural effusion. No nodules. IMPRESSION: Lymphangioleiomyomatosis (LAM) with bilateral thin-walled cysts. HRCT recommended. Rule out tuberous sclerosis.",
|
| 598 |
+
"labels": "",
|
| 599 |
+
},
|
| 600 |
+
{
|
| 601 |
+
"diagnosis": "FINDINGS: Bilateral perihilar and lower zone opacities with consolidation and air bronchograms. Spontaneous pneumothorax noted on the right. No pleural effusion. IMPRESSION: Spontaneous pneumothorax with underlying consolidation. Consider pulmonary contusion or alveolar hemorrhage.",
|
| 602 |
+
"labels": "Pneumothorax|Consolidation",
|
| 603 |
+
},
|
| 604 |
+
{
|
| 605 |
+
"diagnosis": "FINDINGS: Diffuse bilateral airspace opacities with central predominance and air bronchograms. Normal heart size. No pleural effusion. No pneumothorax. IMPRESSION: Acute respiratory distress syndrome (ARDS) with bilateral diffuse airspace disease.",
|
| 606 |
+
"labels": "Infiltration|Consolidation",
|
| 607 |
+
},
|
| 608 |
+
{
|
| 609 |
+
"diagnosis": "FINDINGS: Swyer-James syndrome: hyperlucent left lung with diminished vascular markings. Expiratory film shows air trapping on the left. Left lung is smaller than the right. Heart shifted to the left. No pneumothorax. IMPRESSION: Unilateral hyperlucent lung (Swyer-James/MacLeod syndrome), likely post-infectious in childhood.",
|
| 610 |
+
"labels": "Emphysema",
|
| 611 |
+
},
|
| 612 |
+
{
|
| 613 |
+
"diagnosis": "FINDINGS: Diffuse bilateral ground-glass opacities and consolidation with a peripheral predominance, sparing the costophrenic angles. Reverse halo sign (atoll sign) visible. No pleural effusion. No pneumothorax. IMPRESSION: Organizing pneumonia pattern. Clinical and histopathological correlation recommended.",
|
| 614 |
+
"labels": "Consolidation|Infiltration",
|
| 615 |
+
},
|
| 616 |
+
{
|
| 617 |
+
"diagnosis": "FINDINGS: Multiple cavitary nodules of varying sizes in both lungs with thick walls. Some nodules show feeding vessel signs. No pleural effusion. No pneumothorax. IMPRESSION: Septic pulmonary emboli with cavitation. Sought for source of infection.",
|
| 618 |
+
"labels": "Nodule|Infiltration",
|
| 619 |
+
},
|
| 620 |
+
{
|
| 621 |
+
"diagnosis": "FINDINGS: Bilateral basal and peripheral ground-glass opacities with consolidation. Areas of sparing. Normal heart size. Small left pleural effusion. No pneumothorax. IMPRESSION: Acute eosinophilic pneumonia or cryptogenic organizing pneumonia. Clinical history of eosinophilia needed.",
|
| 622 |
+
"labels": "Consolidation|Effusion|Infiltration",
|
| 623 |
+
},
|
| 624 |
+
{
|
| 625 |
+
"diagnosis": "FINDINGS: Extensive bilateral reticulonodular opacities with a basal predominance. Bronchiectasis and architectural distortion. No pneumothorax. No pleural effusion. IMPRESSION: Rheumatoid arthritis-associated interstitial lung disease with a usual interstitial pneumonia (UIP) pattern.",
|
| 626 |
+
"labels": "Fibrosis|Nodule",
|
| 627 |
+
},
|
| 628 |
+
{
|
| 629 |
+
"diagnosis": "FINDINGS: Scleroderma lung: bilateral lower lobe ground-glass and reticular opacities with early honeycombing. Dilated esophagus with air-fluid level. No pleural effusion. No pneumothorax. IMPRESSION: Systemic sclerosis-associated interstitial lung disease with NSIP pattern and esophageal dilatation.",
|
| 630 |
+
"labels": "Fibrosis|Infiltration",
|
| 631 |
+
},
|
| 632 |
+
{
|
| 633 |
+
"diagnosis": "FINDINGS: Bilateral symmetric lower lobe consolidation with air bronchograms. Kerley B lines and small bilateral pleural effusions present. Heart size normal. No pneumothorax. IMPRESSION: Acute interstitial pneumonia (AIP/Hamman-Rich syndrome). Rapid progression suggests this entity.",
|
| 634 |
+
"labels": "Consolidation|Effusion|Infiltration",
|
| 635 |
+
},
|
| 636 |
+
{
|
| 637 |
+
"diagnosis": "FINDINGS: Multiple subcentimeter nodules with a perilymphatic distribution and bilateral hilar adenopathy. No pleural effusion. No pneumothorax. Lungs otherwise clear. IMPRESSION: Stage I pulmonary sarcoidosis with bilateral hilar lymphadenopathy (1-2-3 sign). No parenchymal involvement.",
|
| 638 |
+
"labels": "Nodule",
|
| 639 |
+
},
|
| 640 |
+
{
|
| 641 |
+
"diagnosis": "FINDINGS: Bilateral hilar and right paratracheal lymphadenopathy with associated upper lobe reticulonodular opacities. No honeycombing. No pleural effusion. No pneumothorax. IMPRESSION: Stage II pulmonary sarcoidosis with bilateral hilar adenopathy and parenchymal involvement.",
|
| 642 |
+
"labels": "Nodule|Fibrosis",
|
| 643 |
+
},
|
| 644 |
+
{
|
| 645 |
+
"diagnosis": "FINDINGS: Diffuse bilateral fine nodules with upper lobe predominance. Mild hilar adenopathy. No fibrosis. No pleural effusion. IMPRESSION: Hypersensitivity pneumonitis presenting with subacute stage. Exposure history correlation recommended.",
|
| 646 |
+
"labels": "Nodule",
|
| 647 |
+
},
|
| 648 |
+
{
|
| 649 |
+
"diagnosis": "FINDINGS: Extensive bilateral consolidations and ground-glass opacities with air bronchograms and air-fluid levels. No definite pleural effusion. No pneumothorax. IMPRESSION: Diffuse alveolar hemorrhage. Consider vasculitis or coagulopathy. Clinical correlation with hemoptysis status urgent.",
|
| 650 |
+
"labels": "Consolidation",
|
| 651 |
+
},
|
| 652 |
+
{
|
| 653 |
+
"diagnosis": "FINDINGS: Unilateral right perihilar mass with obstructive pneumonitis and Golden S sign. Right upper lobe volume loss. No pleural effusion. No pneumothorax. IMPRESSION: Central right lung mass with obstructive atelectasis (Golden S sign suspicious for lung carcinoma). Bronchoscopy and CT recommended.",
|
| 654 |
+
"labels": "Mass|Atelectasis",
|
| 655 |
+
},
|
| 656 |
+
{
|
| 657 |
+
"diagnosis": "FINDINGS: Multiple randomly distributed nodules throughout both lungs with some showing halo sign (ground-glass surrounding nodule). No cavitation. No pleural effusion. No pneumothorax. IMPRESSION: Random nodules with halo sign. Consider fungal infection (aspergillosis, mucormycosis) in immunocompromised patient.",
|
| 658 |
+
"labels": "Nodule|Infiltration",
|
| 659 |
+
},
|
| 660 |
+
{
|
| 661 |
+
"diagnosis": "FINDINGS: Air crescent sign in a preexisting cavity with a round opacity inside. Right upper lobe cavity with intracavitary mass. No pleural effusion. No pneumothorax. IMPRESSION: Aspergilloma (fungus ball) in a preexisting cavity with air crescent sign.",
|
| 662 |
+
"labels": "Nodule|Mass",
|
| 663 |
+
},
|
| 664 |
+
{
|
| 665 |
+
"diagnosis": "FINDINGS: Diffuse bilateral fine miliary nodules with a random distribution. Bilateral hilar adenopathy. No pleural effusion. No pneumothorax. IMPRESSION: Miliary tuberculosis versus metastatic disease. Clinical history and sputum studies recommended.",
|
| 666 |
+
"labels": "Nodule",
|
| 667 |
+
},
|
| 668 |
+
{
|
| 669 |
+
"diagnosis": "FINDINGS: Situs inversus with cardiac apex, aortic knob, and stomach bubble on the right. Dextrocardia with normal cardiac situs. Lungs clear. No pneumothorax. IMPRESSION: Dextrocardia with situs inversus totalis. Consider Kartagener syndrome if bronchiectasis present.",
|
| 670 |
+
"labels": "",
|
| 671 |
+
},
|
| 672 |
+
{
|
| 673 |
+
"diagnosis": "FINDINGS: Pectus excavatum with depressed sternum, right-sided cardiac displacement, and increased retrosternal airspace. Heart size normal. Lungs clear. No pneumothorax. No pleural effusion. IMPRESSION: Pectus excavatum deformity with cardiac displacement. Otherwise normal chest.",
|
| 674 |
+
"labels": "",
|
| 675 |
+
},
|
| 676 |
+
{
|
| 677 |
+
"diagnosis": "FINDINGS: Absence of the right pectoralis muscle with hyperlucency of the right hemithorax. No mediastinal shift. No lung herniation. Lungs clear. No pneumothorax. IMPRESSION: Poland syndrome with absent right pectoralis muscle and right-sided hyperlucency.",
|
| 678 |
+
"labels": "",
|
| 679 |
+
},
|
| 680 |
+
{
|
| 681 |
+
"diagnosis": "FINDINGS: Cervical rib arising from the C7 vertebra bilaterally. No associated thoracic outlet syndrome findings. Lungs clear. Heart size normal. No pneumothorax. IMPRESSION: Bilateral cervical ribs, an incidental finding.",
|
| 682 |
+
"labels": "",
|
| 683 |
+
},
|
| 684 |
+
{
|
| 685 |
+
"diagnosis": "FINDINGS: Azygos lobe with a visible azygos fissure forming a tear-drop shaped opacity at the right apex. The azygos vein is seen at the base of the fissure. Lungs clear. No other abnormality. IMPRESSION: Azygos lobe, a normal variant. No pathological significance.",
|
| 686 |
+
"labels": "No Finding",
|
| 687 |
+
},
|
| 688 |
+
{
|
| 689 |
+
"diagnosis": "FINDINGS: Diffuse interstitial pulmonary calcification with bilateral dense nodular and conglomerate opacities. No pleural effusion. No pneumothorax. Heart size normal. IMPRESSION: Metastatic pulmonary calcification, likely secondary to chronic renal failure or hyperparathyroidism.",
|
| 690 |
+
"labels": "Nodule|Fibrosis",
|
| 691 |
+
},
|
| 692 |
+
{
|
| 693 |
+
"diagnosis": "FINDINGS: Unilateral right lung hyperlucency with diminished vascular markings. Expiratory views show air trapping. No mediastinal shift. No pneumothorax. IMPRESSION: Swyer-James syndrome (post-infectious obliterative bronchiolitis) causing right lung hyperlucency.",
|
| 694 |
+
"labels": "",
|
| 695 |
+
},
|
| 696 |
+
{
|
| 697 |
+
"diagnosis": "FINDINGS: Bilateral symmetrical consolidation with air bronchograms in the lower lobes. Rapid progression over 24 hours. No pleural effusion. No pneumothorax. Normal heart size. IMPRESSION: Rapid progression of bilateral airspace disease suspicious for ARDS or diffuse alveolar hemorrhage. Clinical history critical.",
|
| 698 |
+
"labels": "Consolidation",
|
| 699 |
+
},
|
| 700 |
+
{
|
| 701 |
+
"diagnosis": "FINDINGS: Large bulla in the right upper lobe occupying more than 30% of the hemithorax. Compressed adjacent lung. No pneumothorax. No pleural effusion. Remaining lung is hyperexpanded and shows emphysematous change. IMPRESSION: Giant bulla in the right upper lobe. Consider bullectomy if symptomatic.",
|
| 702 |
+
"labels": "Emphysema",
|
| 703 |
+
},
|
| 704 |
+
{
|
| 705 |
+
"diagnosis": "FINDINGS: Bilateral apical pleural thickening with associated fibrotic band. No definite mass. No cavitation. No calcification. No pleural effusion. No pneumothorax. IMPRESSION: Biapical pleural thickening, likely post-inflammatory. If new or progressive, consider Pancoast tumor and CT correlation.",
|
| 706 |
+
"labels": "Pleural_Thickening|Fibrosis",
|
| 707 |
+
},
|
| 708 |
+
{
|
| 709 |
+
"diagnosis": "FINDINGS: Extensive bilateral nodular and reticular opacities in a perihilar distribution with traction bronchiectasis. No honeycombing. No pleural effusion. No pneumothorax. IMPRESSION: Lymphangitic carcinomatosis. Known history of malignancy correlates with this appearance.",
|
| 710 |
+
"labels": "Nodule|Fibrosis",
|
| 711 |
+
},
|
| 712 |
+
{
|
| 713 |
+
"diagnosis": "FINDINGS: Air-fluid level in a preexisting cystic lesion in the right lower lobe. Surrounding consolidation. The cyst appears to have an intracavitary mass. No pneumothorax. Small pleural effusion on the right. IMPRESSION: Infected lung cyst with possible intracavitary mass, likely an aspergilloma or lung abscess.",
|
| 714 |
+
"labels": "Consolidation|Effusion|Mass",
|
| 715 |
+
},
|
| 716 |
+
{
|
| 717 |
+
"diagnosis": "FINDINGS: Bilateral subpleural reticular opacities with basal honeycombing and traction bronchiectasis. Progressive from prior exam. No pneumothorax. No pleural effusion. Normal heart size. IMPRESSION: Idiopathic pulmonary fibrosis with decline and honeycombing progression. Pulmonary function correlation recommended.",
|
| 718 |
+
"labels": "Fibrosis",
|
| 719 |
+
},
|
| 720 |
+
{
|
| 721 |
+
"diagnosis": "FINDINGS: Fat-fluid level and air-fluid level within a large loculated pleural collection on the left. Pneumothorax is absent. The underlying lung is partially compressed. IMPRESSION: Empyema necessitans or complicated parapneumonic effusion with air-fluid level. Diagnostic thoracentesis recommended.",
|
| 722 |
+
"labels": "Effusion|Pneumothorax",
|
| 723 |
+
},
|
| 724 |
+
{
|
| 725 |
+
"diagnosis": "FINDINGS: Bilaterally enlarged pulmonary arteries with pruning of peripheral vessels. Right ventricular enlargement. No pleural effusion. No pneumothorax. IMPRESSION: Pulmonary arterial hypertension with enlarged central pulmonary arteries and right ventricular enlargement.",
|
| 726 |
+
"labels": "",
|
| 727 |
+
},
|
| 728 |
+
{
|
| 729 |
+
"diagnosis": "FINDINGS: Spontaneous pneumomediastinum with air outlining the mediastinal structures, including the thymus (spinnaker sail sign), heart border, and great vessels. Subcutaneous emphysema in the neck. No pneumothorax. No pleural effusion. IMPRESSION: Pneumomediastinum with subcutaneous emphysema. Likely due to airway rupture or Valsalva.",
|
| 730 |
+
"labels": "",
|
| 731 |
+
},
|
| 732 |
+
{
|
| 733 |
+
"diagnosis": "FINDINGS: Extensive subcutaneous emphysema in the chest wall extending to the neck. No pneumothorax. No pneumomediastinum. Multiple chest tubes in place. Lungs are partially aerated. IMPRESSION: Subcutaneous emphysema, likely from chest tube or traumatic air leak.",
|
| 734 |
+
"labels": "",
|
| 735 |
+
},
|
| 736 |
+
{
|
| 737 |
+
"diagnosis": "FINDINGS: Bochdalek hernia defect on the left with herniated abdominal contents (stomach and bowel) visible in the left hemithorax. Mediastinal shift to the right. No pneumothorax. No pleural effusion. IMPRESSION: Bochdalek hernia containing abdominal viscera. Congenital or acquired diaphragmatic hernia.",
|
| 738 |
+
"labels": "Hernia",
|
| 739 |
+
},
|
| 740 |
+
{
|
| 741 |
+
"diagnosis": "FINDINGS: Morgagni hernia in the right anterior cardiophrenic angle with omental fat herniating through the foramen of Morgagni. No bowel obstruction. No pleural effusion. No pneumothorax. IMPRESSION: Morgagni hernia containing omental fat. Usually asymptomatic and benign.",
|
| 742 |
+
"labels": "Hernia",
|
| 743 |
+
},
|
| 744 |
+
{
|
| 745 |
+
"diagnosis": "FINDINGS: Eventration of the right hemidiaphragm with marked elevation. The underlying lung shows compressive atelectasis. No pneumothorax. No pleural effusion. Heart shifted to the left. IMPRESSION: Right hemidiaphragm eventration with adjacent atelectasis. May mimic diaphragmatic rupture.",
|
| 746 |
+
"labels": "Atelectasis",
|
| 747 |
+
},
|
| 748 |
+
{
|
| 749 |
+
"diagnosis": "FINDINGS: Diaphragmatic rupture on the left with stomach herniated into the left hemithorax. The nasogastric tube is coiled above the diaphragm. Mediastinal shift to the right. No pneumothorax. Left pleural effusion. IMPRESSION: Left diaphragmatic rupture with gastric herniation. Emergency surgical repair needed.",
|
| 750 |
+
"labels": "Effusion|Hernia",
|
| 751 |
+
},
|
| 752 |
+
]
|
| 753 |
+
|
| 754 |
+
# Add more rows with varied symptoms cycling through existing images
|
| 755 |
+
def generate_extended_rows():
|
| 756 |
+
rows = []
|
| 757 |
+
img_idx = 0
|
| 758 |
+
num_images = len(IMAGES)
|
| 759 |
+
|
| 760 |
+
symptom_idx = 0
|
| 761 |
+
for diag in DIAGNOSIS_ENTRIES:
|
| 762 |
+
img_path = IMAGES[img_idx % num_images]
|
| 763 |
+
img_idx += 1
|
| 764 |
+
|
| 765 |
+
symptom = SYMPTOM_TEMPLATES[symptom_idx % len(SYMPTOM_TEMPLATES)]
|
| 766 |
+
symptom_idx += 1
|
| 767 |
+
|
| 768 |
+
labels = diag["labels"]
|
| 769 |
+
diagnosis = diag["diagnosis"]
|
| 770 |
+
|
| 771 |
+
rows.append([img_path, "augmented", symptom, diagnosis, labels])
|
| 772 |
+
|
| 773 |
+
return rows
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
if __name__ == "__main__":
|
| 777 |
+
initial_count = count_rows()
|
| 778 |
+
print(f"Current dataset rows: {initial_count}")
|
| 779 |
+
|
| 780 |
+
new_rows = generate_extended_rows()
|
| 781 |
+
print(f"Adding {len(new_rows)} new rows...")
|
| 782 |
+
|
| 783 |
+
with open(CSV_PATH, "a", newline="", encoding="utf-8") as f:
|
| 784 |
+
writer = csv.writer(f)
|
| 785 |
+
writer.writerows(new_rows)
|
| 786 |
+
|
| 787 |
+
final_count = count_rows()
|
| 788 |
+
print(f"New dataset rows: {final_count}")
|
| 789 |
+
print(f"Added {final_count - initial_count} rows")
|
| 790 |
+
print("Done!")
|
launch.ps1
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<#
|
| 2 |
+
.SYNOPSIS
|
| 3 |
+
One-click launcher for MedicalAI - Light Weight
|
| 4 |
+
.DESCRIPTION
|
| 5 |
+
Checks for Python, sets up venv, installs deps, and launches the app.
|
| 6 |
+
Double-click this file or run: powershell -File launch.ps1
|
| 7 |
+
#>
|
| 8 |
+
|
| 9 |
+
$ErrorActionPreference = "Stop"
|
| 10 |
+
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
| 11 |
+
Set-Location $ProjectRoot
|
| 12 |
+
|
| 13 |
+
$Host.UI.RawUI.WindowTitle = "MedicalAI - Light Weight"
|
| 14 |
+
|
| 15 |
+
# ── Check Python ──
|
| 16 |
+
$python = $null
|
| 17 |
+
foreach ($cmd in @("python", "python3", "py")) {
|
| 18 |
+
try {
|
| 19 |
+
$v = & $cmd --version 2>&1
|
| 20 |
+
if ($v -match "Python 3\.(1[0-9]|[0-9]+)") {
|
| 21 |
+
$python = $cmd
|
| 22 |
+
break
|
| 23 |
+
}
|
| 24 |
+
} catch {}
|
| 25 |
+
}
|
| 26 |
+
if (-not $python) {
|
| 27 |
+
Write-Host "Python 3.10+ is required but not found." -ForegroundColor Red
|
| 28 |
+
Write-Host "Download from: https://www.python.org/downloads/" -ForegroundColor Yellow
|
| 29 |
+
Write-Host "Make sure to check 'Add Python to PATH' during installation." -ForegroundColor Yellow
|
| 30 |
+
Read-Host "Press Enter to exit"
|
| 31 |
+
exit 1
|
| 32 |
+
}
|
| 33 |
+
Write-Host "Using: $(& $python --version)" -ForegroundColor Green
|
| 34 |
+
|
| 35 |
+
# ── Virtual Environment ──
|
| 36 |
+
$venvPath = Join-Path $ProjectRoot ".venv"
|
| 37 |
+
if (-not (Test-Path $venvPath)) {
|
| 38 |
+
Write-Host "Creating virtual environment..." -ForegroundColor Cyan
|
| 39 |
+
& $python -m venv $venvPath
|
| 40 |
+
if (-not $?) { throw "Failed to create venv" }
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# ── Activate ──
|
| 44 |
+
$activate = Join-Path $venvPath "Scripts\Activate.ps1"
|
| 45 |
+
. $activate
|
| 46 |
+
|
| 47 |
+
# ── Install Dependencies ──
|
| 48 |
+
$reqPath = Join-Path $ProjectRoot "requirements.txt"
|
| 49 |
+
if (Test-Path $reqPath) {
|
| 50 |
+
Write-Host "Installing dependencies..." -ForegroundColor Cyan
|
| 51 |
+
pip install -q -r $reqPath 2>&1 | Out-Null
|
| 52 |
+
if (-not $?) {
|
| 53 |
+
Write-Host "Retrying with full output..." -ForegroundColor Yellow
|
| 54 |
+
pip install -r $reqPath
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# ── Check / Generate Default Models ──
|
| 59 |
+
$defaultClassifier = Join-Path $ProjectRoot "models\default\fusion_classifier.onnx"
|
| 60 |
+
$checkpoint = Join-Path $ProjectRoot "checkpoints\fusion_model.pth"
|
| 61 |
+
$onnxFull = Join-Path $ProjectRoot "checkpoints\onnx_full\fusion_full.onnx"
|
| 62 |
+
|
| 63 |
+
if ((-not (Test-Path $checkpoint)) -and (-not (Test-Path $onnxFull)) -and (-not (Test-Path $defaultClassifier))) {
|
| 64 |
+
Write-Host "No models found. Generating default models..." -ForegroundColor Yellow
|
| 65 |
+
python setup_default.py
|
| 66 |
+
Write-Host "Default models generated. The app will work immediately." -ForegroundColor Green
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (Test-Path $checkpoint) {
|
| 70 |
+
Write-Host "Trained model found." -ForegroundColor Green
|
| 71 |
+
} elseif (Test-Path $onnxFull) {
|
| 72 |
+
Write-Host "ONNX pipeline found." -ForegroundColor Green
|
| 73 |
+
} elseif (Test-Path $defaultClassifier) {
|
| 74 |
+
Write-Host "Default model found. Train a proper model for accurate results." -ForegroundColor Yellow
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
# ── Ask how to launch ──
|
| 78 |
+
Write-Host ""
|
| 79 |
+
Write-Host "MedicalAI - Light Weight" -ForegroundColor Cyan
|
| 80 |
+
Write-Host "========================" -ForegroundColor Cyan
|
| 81 |
+
Write-Host "1) Web UI (recommended - opens in browser)"
|
| 82 |
+
Write-Host "2) Command-line interface (CLI)"
|
| 83 |
+
Write-Host "3) API Server (for website/app integration)"
|
| 84 |
+
Write-Host ""
|
| 85 |
+
|
| 86 |
+
$choice = Read-Host "Select (1, 2, or 3)"
|
| 87 |
+
|
| 88 |
+
if ($choice -eq "2") {
|
| 89 |
+
Write-Host "Launching CLI..." -ForegroundColor Green
|
| 90 |
+
python run.py
|
| 91 |
+
} elseif ($choice -eq "3") {
|
| 92 |
+
# Ensure API deps are installed
|
| 93 |
+
try {
|
| 94 |
+
Import-Module python -ErrorAction Stop
|
| 95 |
+
python -c "import fastapi" 2>$null
|
| 96 |
+
} catch {
|
| 97 |
+
Write-Host "Installing API dependencies..." -ForegroundColor Cyan
|
| 98 |
+
python -m pip install "fastapi[standard]" uvicorn 2>&1 | Out-Null
|
| 99 |
+
}
|
| 100 |
+
Write-Host "Launching API server on http://127.0.0.1:8000 ..." -ForegroundColor Green
|
| 101 |
+
Write-Host "Your website can connect to: http://127.0.0.1:8000/api" -ForegroundColor Yellow
|
| 102 |
+
python quantization.py --mode serve-api
|
| 103 |
+
} else {
|
| 104 |
+
Write-Host "Launching web UI..." -ForegroundColor Green
|
| 105 |
+
python web_ui.py
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
# ── Keep window open on crash ──
|
| 109 |
+
if (-not $?) {
|
| 110 |
+
Write-Host "App exited with error. Press Enter to close." -ForegroundColor Red
|
| 111 |
+
Read-Host
|
| 112 |
+
}
|
launch.sh
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# One-click launcher for MedicalAI on Linux/Mac
|
| 3 |
+
set -e
|
| 4 |
+
|
| 5 |
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
| 6 |
+
cd "$SCRIPT_DIR"
|
| 7 |
+
|
| 8 |
+
# ── Check Python ──
|
| 9 |
+
PYTHON=""
|
| 10 |
+
for cmd in python3 python; do
|
| 11 |
+
if command -v "$cmd" &>/dev/null; then
|
| 12 |
+
ver=$("$cmd" --version 2>&1)
|
| 13 |
+
if echo "$ver" | grep -qE "Python 3\.(1[0-9]|[0-9]+)"; then
|
| 14 |
+
PYTHON="$cmd"
|
| 15 |
+
break
|
| 16 |
+
fi
|
| 17 |
+
fi
|
| 18 |
+
done
|
| 19 |
+
|
| 20 |
+
if [ -z "$PYTHON" ]; then
|
| 21 |
+
echo "Error: Python 3.10+ is required but not found."
|
| 22 |
+
echo "Install from: https://www.python.org/downloads/"
|
| 23 |
+
read -rp "Press Enter to exit"
|
| 24 |
+
exit 1
|
| 25 |
+
fi
|
| 26 |
+
echo "Using: $($PYTHON --version)"
|
| 27 |
+
|
| 28 |
+
# ── Virtual Environment ──
|
| 29 |
+
if [ ! -d ".venv" ]; then
|
| 30 |
+
echo "Creating virtual environment..."
|
| 31 |
+
$PYTHON -m venv .venv
|
| 32 |
+
fi
|
| 33 |
+
|
| 34 |
+
source .venv/bin/activate
|
| 35 |
+
|
| 36 |
+
# ── Install Dependencies ──
|
| 37 |
+
if [ -f "requirements.txt" ]; then
|
| 38 |
+
echo "Installing dependencies..."
|
| 39 |
+
pip install -q -r requirements.txt 2>/dev/null || pip install -r requirements.txt
|
| 40 |
+
fi
|
| 41 |
+
|
| 42 |
+
# ── Check / Generate Default Models ──
|
| 43 |
+
if [ ! -f "checkpoints/fusion_model.pth" ] && \
|
| 44 |
+
[ ! -f "checkpoints/onnx_full/fusion_full.onnx" ] && \
|
| 45 |
+
[ ! -f "models/default/fusion_classifier.onnx" ]; then
|
| 46 |
+
echo "No models found. Generating default models..."
|
| 47 |
+
python setup_default.py
|
| 48 |
+
echo "Default models generated. The app will work immediately."
|
| 49 |
+
fi
|
| 50 |
+
|
| 51 |
+
if [ -f "checkpoints/fusion_model.pth" ]; then
|
| 52 |
+
echo "Trained model found."
|
| 53 |
+
elif [ -f "checkpoints/onnx_full/fusion_full.onnx" ]; then
|
| 54 |
+
echo "ONNX pipeline found."
|
| 55 |
+
elif [ -f "models/default/fusion_classifier.onnx" ]; then
|
| 56 |
+
echo "Default model found. Train for accurate results."
|
| 57 |
+
fi
|
| 58 |
+
|
| 59 |
+
# ── Launch ──
|
| 60 |
+
echo ""
|
| 61 |
+
echo "MedicalAI - Light Weight"
|
| 62 |
+
echo "========================"
|
| 63 |
+
echo "1) Web UI (recommended - opens in browser)"
|
| 64 |
+
echo "2) Command-line interface (CLI)"
|
| 65 |
+
echo "3) API Server (for website/app integration)"
|
| 66 |
+
echo ""
|
| 67 |
+
read -rp "Select (1, 2, or 3): " choice
|
| 68 |
+
|
| 69 |
+
if [ "$choice" = "2" ]; then
|
| 70 |
+
echo "Launching CLI..."
|
| 71 |
+
python run.py
|
| 72 |
+
elif [ "$choice" = "3" ]; then
|
| 73 |
+
echo "Launching API server on http://127.0.0.1:8000 ..."
|
| 74 |
+
echo "Your website can connect to: http://127.0.0.1:8000/api"
|
| 75 |
+
pip install -q "fastapi[standard]" uvicorn 2>/dev/null || pip install "fastapi[standard]" uvicorn
|
| 76 |
+
python quantization.py --mode serve-api
|
| 77 |
+
else
|
| 78 |
+
if python -c "import gradio" 2>/dev/null; then
|
| 79 |
+
python web_ui.py
|
| 80 |
+
else
|
| 81 |
+
echo "Installing gradio..."
|
| 82 |
+
pip install gradio
|
| 83 |
+
python web_ui.py
|
| 84 |
+
fi
|
| 85 |
+
fi
|
models/default/fusion_classifier.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:91e573923c145522cc0c2a166e055439e6022f042dc917f287f41210275245f3
|
| 3 |
+
size 4852
|
models/default/fusion_classifier.onnx.data
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:546b236c5ee8445995e0acf16b566f7f021f538d128678a33f747eda52f7354c
|
| 3 |
+
size 1376256
|
models/default/labels.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
["No Finding", "Atelectasis", "Cardiomegaly", "Effusion", "Infiltration", "Mass", "Nodule", "Pneumonia", "Pneumothorax", "Consolidation", "Edema", "Emphysema", "Fibrosis", "Pleural_Thickening", "Hernia"]
|
models/default/symptoms.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
What abnormality is present in this chest X-ray?
|
| 2 |
+
Patient presents with shortness of breath and cough.
|
| 3 |
+
Fever and productive cough for 5 days.
|
| 4 |
+
Chest pain and dyspnea on exertion.
|
| 5 |
+
Routine pre-operative chest X-ray.
|
| 6 |
+
Trauma patient. Evaluate for pneumothorax or fractures.
|
| 7 |
+
Patient with history of smoking. Evaluate for lung pathology.
|
| 8 |
+
Immunocompromised patient with fever.
|
optimize.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gc
|
| 2 |
+
import os
|
| 3 |
+
import threading
|
| 4 |
+
|
| 5 |
+
import psutil
|
| 6 |
+
import torch
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
MODEL_DIR = "./blip-xray-finetuned"
|
| 10 |
+
CHECKPOINT_DIR = "./checkpoints"
|
| 11 |
+
CHECKPOINT_PATH = os.path.join(CHECKPOINT_DIR, "fusion_model.pth")
|
| 12 |
+
ONNX_DIR = os.path.join(MODEL_DIR, "onnx")
|
| 13 |
+
DEFAULT_MODEL_DIR = os.path.join("models", "default")
|
| 14 |
+
DEFAULT_CLASSIFIER_ONNX = os.path.join(DEFAULT_MODEL_DIR, "fusion_classifier.onnx")
|
| 15 |
+
DEFAULT_LABELS_PATH = os.path.join(DEFAULT_MODEL_DIR, "labels.json")
|
| 16 |
+
ONNX_FULL_DIR = os.path.join(CHECKPOINT_DIR, "onnx_full")
|
| 17 |
+
ONNX_FULL_PATH = os.path.join(ONNX_FULL_DIR, "fusion_full.onnx")
|
| 18 |
+
ONNX_FULL_LABELS = os.path.join(ONNX_FULL_DIR, "labels.json")
|
| 19 |
+
|
| 20 |
+
NIH_LABELS = [
|
| 21 |
+
"No Finding", "Atelectasis", "Cardiomegaly", "Effusion", "Infiltration",
|
| 22 |
+
"Mass", "Nodule", "Pneumonia", "Pneumothorax", "Consolidation",
|
| 23 |
+
"Edema", "Emphysema", "Fibrosis", "Pleural_Thickening", "Hernia",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
_loaded_blip = None
|
| 27 |
+
_loaded_fusion = None
|
| 28 |
+
|
| 29 |
+
# ── CPU Thread Control ────────────────────────────────────────
|
| 30 |
+
|
| 31 |
+
def set_cpu_threads(n=None):
|
| 32 |
+
if n is None:
|
| 33 |
+
n = max(1, psutil.cpu_count(logical=True) // 2)
|
| 34 |
+
os.environ["OMP_NUM_THREADS"] = str(n)
|
| 35 |
+
os.environ["MKL_NUM_THREADS"] = str(n)
|
| 36 |
+
os.environ["NUMEXPR_NUM_THREADS"] = str(n)
|
| 37 |
+
torch.set_num_threads(n)
|
| 38 |
+
return n
|
| 39 |
+
|
| 40 |
+
# ── Memory ────────────────────────────────────────────────────
|
| 41 |
+
|
| 42 |
+
def get_memory_usage():
|
| 43 |
+
proc = psutil.Process()
|
| 44 |
+
mem = proc.memory_info()
|
| 45 |
+
return {
|
| 46 |
+
"rss_mb": mem.rss / 1024 / 1024,
|
| 47 |
+
"vms_mb": mem.vms / 1024 / 1024,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
def clear_memory():
|
| 51 |
+
global _loaded_blip, _loaded_fusion
|
| 52 |
+
_loaded_blip = None
|
| 53 |
+
_loaded_fusion = None
|
| 54 |
+
gc.collect()
|
| 55 |
+
if torch.cuda.is_available():
|
| 56 |
+
torch.cuda.empty_cache()
|
| 57 |
+
if torch.backends.mps.is_available():
|
| 58 |
+
torch.mps.empty_cache()
|
| 59 |
+
|
| 60 |
+
# ── Device ─────────────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
def get_device():
|
| 63 |
+
if torch.cuda.is_available():
|
| 64 |
+
return "cuda"
|
| 65 |
+
if torch.backends.mps.is_available():
|
| 66 |
+
return "mps"
|
| 67 |
+
return "cpu"
|
| 68 |
+
|
| 69 |
+
def use_fp16():
|
| 70 |
+
return get_device() in ("cuda", "mps")
|
| 71 |
+
|
| 72 |
+
# ── BLIP / Vision inference ───────────────────────────────────
|
| 73 |
+
|
| 74 |
+
def infer_blip(image_path, use_onnx=True):
|
| 75 |
+
global _loaded_blip
|
| 76 |
+
image = Image.open(image_path).convert("RGB")
|
| 77 |
+
|
| 78 |
+
if use_onnx and os.path.exists(os.path.join(ONNX_DIR, "model.onnx")):
|
| 79 |
+
return _infer_blip_onnx(image)
|
| 80 |
+
return _infer_blip_pytorch(image)
|
| 81 |
+
|
| 82 |
+
def _infer_blip_pytorch(image):
|
| 83 |
+
global _loaded_blip
|
| 84 |
+
if _loaded_blip is None:
|
| 85 |
+
from transformers import BlipProcessor, BlipForConditionalGeneration
|
| 86 |
+
model_name = MODEL_DIR if os.path.exists(MODEL_DIR) else "Salesforce/blip-image-captioning-base"
|
| 87 |
+
_loaded_blip = {
|
| 88 |
+
"processor": BlipProcessor.from_pretrained(model_name),
|
| 89 |
+
"model": BlipForConditionalGeneration.from_pretrained(model_name).eval(),
|
| 90 |
+
}
|
| 91 |
+
if use_fp16() and hasattr(torch, "float16"):
|
| 92 |
+
try:
|
| 93 |
+
_loaded_blip["model"] = _loaded_blip["model"].half()
|
| 94 |
+
except Exception:
|
| 95 |
+
pass
|
| 96 |
+
|
| 97 |
+
p, m = _loaded_blip["processor"], _loaded_blip["model"]
|
| 98 |
+
inputs = p(images=image, return_tensors="pt")
|
| 99 |
+
if use_fp16():
|
| 100 |
+
inputs = {k: v.half() if v.dtype == torch.float32 else v for k, v in inputs.items()}
|
| 101 |
+
with torch.no_grad():
|
| 102 |
+
out = m.generate(**inputs, max_length=64)
|
| 103 |
+
return p.decode(out[0], skip_special_tokens=True)
|
| 104 |
+
|
| 105 |
+
def _infer_blip_onnx(image):
|
| 106 |
+
from optimum.onnxruntime import ORTModelForVision2Seq
|
| 107 |
+
from transformers import BlipProcessor
|
| 108 |
+
processor = BlipProcessor.from_pretrained(ONNX_DIR)
|
| 109 |
+
model = ORTModelForVision2Seq.from_pretrained(ONNX_DIR, provider="CPUExecutionProvider")
|
| 110 |
+
inputs = processor(images=image, return_tensors="np")
|
| 111 |
+
out = model.generate(**inputs, max_length=64)
|
| 112 |
+
return processor.decode(out[0], skip_special_tokens=True)
|
| 113 |
+
|
| 114 |
+
# ── Fusion / Symptom Check inference ──────────────────────────
|
| 115 |
+
|
| 116 |
+
def get_available_models():
|
| 117 |
+
"""Return a dict describing which models are available."""
|
| 118 |
+
return {
|
| 119 |
+
"trained_pytorch": os.path.exists(CHECKPOINT_PATH),
|
| 120 |
+
"default_classifier": os.path.exists(DEFAULT_CLASSIFIER_ONNX),
|
| 121 |
+
"onnx_full_pipeline": os.path.exists(ONNX_FULL_PATH),
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _infer_fusion_default(image_path, symptoms):
|
| 126 |
+
"""Fallback: use default ONNX classifier with stock PyTorch encoders."""
|
| 127 |
+
global _loaded_fusion
|
| 128 |
+
if _loaded_fusion is None:
|
| 129 |
+
import json
|
| 130 |
+
from transformers import CLIPModel, CLIPProcessor, AutoTokenizer, AutoModel
|
| 131 |
+
from training import DiagnosisFusionModel
|
| 132 |
+
|
| 133 |
+
label_list = NIH_LABELS
|
| 134 |
+
model = DiagnosisFusionModel(num_conditions=len(label_list))
|
| 135 |
+
# Reset classifier to random weights if no checkpoint
|
| 136 |
+
if not os.path.exists(CHECKPOINT_PATH):
|
| 137 |
+
for layer in model.classifier:
|
| 138 |
+
if hasattr(layer, "reset_parameters"):
|
| 139 |
+
layer.reset_parameters()
|
| 140 |
+
_loaded_fusion = {
|
| 141 |
+
"model": model.eval(),
|
| 142 |
+
"label_list": label_list,
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
image = Image.open(image_path).convert("RGB")
|
| 146 |
+
m = _loaded_fusion["model"]
|
| 147 |
+
label_list = _loaded_fusion["label_list"]
|
| 148 |
+
|
| 149 |
+
with torch.no_grad():
|
| 150 |
+
logits = m([image], [symptoms])
|
| 151 |
+
probs = torch.softmax(logits, dim=-1)
|
| 152 |
+
confidence, predicted = torch.max(probs, dim=-1)
|
| 153 |
+
|
| 154 |
+
return label_list[predicted.item()], confidence.item()
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def infer_fusion(image_path, symptoms):
|
| 158 |
+
global _loaded_fusion
|
| 159 |
+
|
| 160 |
+
# Priority 1: Trained PyTorch model
|
| 161 |
+
if _loaded_fusion is None and os.path.exists(CHECKPOINT_PATH):
|
| 162 |
+
from training import DiagnosisFusionModel
|
| 163 |
+
checkpoint = torch.load(CHECKPOINT_PATH, weights_only=False)
|
| 164 |
+
label_list = checkpoint.get("label_list", [])
|
| 165 |
+
if label_list:
|
| 166 |
+
model = DiagnosisFusionModel(num_conditions=len(label_list))
|
| 167 |
+
model.classifier.load_state_dict(checkpoint["model_state"])
|
| 168 |
+
_loaded_fusion = {
|
| 169 |
+
"model": model.eval(),
|
| 170 |
+
"label_list": label_list,
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
# Priority 2: Default model (stock encoders + fresh classifier)
|
| 174 |
+
if _loaded_fusion is None:
|
| 175 |
+
return _infer_fusion_default(image_path, symptoms)
|
| 176 |
+
|
| 177 |
+
image = Image.open(image_path).convert("RGB")
|
| 178 |
+
m = _loaded_fusion["model"]
|
| 179 |
+
label_list = _loaded_fusion["label_list"]
|
| 180 |
+
|
| 181 |
+
with torch.no_grad():
|
| 182 |
+
logits = m([image], [symptoms])
|
| 183 |
+
probs = torch.softmax(logits, dim=-1)
|
| 184 |
+
confidence, predicted = torch.max(probs, dim=-1)
|
| 185 |
+
|
| 186 |
+
return label_list[predicted.item()], confidence.item()
|
| 187 |
+
|
| 188 |
+
# ── Full ONNX Pipeline Export ─────────────────────────────────
|
| 189 |
+
|
| 190 |
+
def _ensure_onnx_deps():
|
| 191 |
+
try:
|
| 192 |
+
import onnx # noqa: F401
|
| 193 |
+
return True
|
| 194 |
+
except ImportError:
|
| 195 |
+
from rich.console import Console
|
| 196 |
+
console = Console()
|
| 197 |
+
console.print("[yellow]Installing ONNX dependencies...[/yellow]")
|
| 198 |
+
import subprocess, sys
|
| 199 |
+
subprocess.check_call([
|
| 200 |
+
sys.executable, "-m", "pip", "install",
|
| 201 |
+
"onnx", "onnxruntime", "onnxscript",
|
| 202 |
+
])
|
| 203 |
+
return True
|
| 204 |
+
|
| 205 |
+
def _load_fusion_model():
|
| 206 |
+
from training import DiagnosisFusionModel
|
| 207 |
+
checkpoint = torch.load(CHECKPOINT_PATH, weights_only=False)
|
| 208 |
+
label_list = checkpoint.get("label_list", [])
|
| 209 |
+
if not label_list:
|
| 210 |
+
return None, None
|
| 211 |
+
model = DiagnosisFusionModel(num_conditions=len(label_list))
|
| 212 |
+
model.classifier.load_state_dict(checkpoint["model_state"])
|
| 213 |
+
model.eval()
|
| 214 |
+
return model, label_list
|
| 215 |
+
|
| 216 |
+
class _FullFusionONNXWrapper(torch.nn.Module):
|
| 217 |
+
"""Wraps the full fusion pipeline so torch.onnx.export can trace it end-to-end."""
|
| 218 |
+
def __init__(self, model):
|
| 219 |
+
super().__init__()
|
| 220 |
+
self.image_encoder = model.image_encoder.vision_model
|
| 221 |
+
self.symptom_encoder = model.symptom_encoder
|
| 222 |
+
self.classifier = model.classifier
|
| 223 |
+
self.image_proj = model.image_encoder.visual_projection
|
| 224 |
+
|
| 225 |
+
def forward(self, pixel_values, input_ids, attention_mask):
|
| 226 |
+
vision_outputs = self.image_encoder(pixel_values)
|
| 227 |
+
image_features = self.image_proj(vision_outputs.pooler_output)
|
| 228 |
+
image_features = image_features / image_features.norm(dim=-1, keepdim=True)
|
| 229 |
+
|
| 230 |
+
text_outputs = self.symptom_encoder(input_ids, attention_mask=attention_mask)
|
| 231 |
+
text_features = text_outputs.last_hidden_state.mean(dim=1)
|
| 232 |
+
|
| 233 |
+
combined = torch.cat([image_features, text_features], dim=-1)
|
| 234 |
+
return self.classifier(combined)
|
| 235 |
+
|
| 236 |
+
def export_full_fusion_onnx(output_dir=None):
|
| 237 |
+
"""Export the entire fusion pipeline (image + text -> logits) to a single ONNX file."""
|
| 238 |
+
if output_dir is None:
|
| 239 |
+
output_dir = os.path.join(CHECKPOINT_DIR, "onnx_full")
|
| 240 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 241 |
+
|
| 242 |
+
from rich.console import Console
|
| 243 |
+
console = Console()
|
| 244 |
+
|
| 245 |
+
model, label_list = _load_fusion_model()
|
| 246 |
+
if model is None:
|
| 247 |
+
console.print("[red]No model or labels found. Train first.[/red]")
|
| 248 |
+
return
|
| 249 |
+
|
| 250 |
+
_ensure_onnx_deps()
|
| 251 |
+
|
| 252 |
+
wrapper = _FullFusionONNXWrapper(model).eval()
|
| 253 |
+
|
| 254 |
+
dummy_pixel = torch.randn(1, 3, 224, 224)
|
| 255 |
+
dummy_ids = torch.randint(0, 100, (1, 64), dtype=torch.long)
|
| 256 |
+
dummy_mask = torch.ones(1, 64, dtype=torch.long)
|
| 257 |
+
|
| 258 |
+
console.print("[cyan]Exporting full fusion pipeline to ONNX...[/cyan]")
|
| 259 |
+
|
| 260 |
+
torch.onnx.export(
|
| 261 |
+
wrapper,
|
| 262 |
+
(dummy_pixel, dummy_ids, dummy_mask),
|
| 263 |
+
os.path.join(output_dir, "fusion_full.onnx"),
|
| 264 |
+
input_names=["pixel_values", "input_ids", "attention_mask"],
|
| 265 |
+
output_names=["logits"],
|
| 266 |
+
opset_version=14,
|
| 267 |
+
dynamic_axes={
|
| 268 |
+
"input_ids": {0: "batch_size", 1: "seq_len"},
|
| 269 |
+
"attention_mask": {0: "batch_size", 1: "seq_len"},
|
| 270 |
+
"pixel_values": {0: "batch_size"},
|
| 271 |
+
"logits": {0: "batch_size"},
|
| 272 |
+
},
|
| 273 |
+
dynamo=False,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
import json
|
| 277 |
+
with open(os.path.join(output_dir, "labels.json"), "w") as f:
|
| 278 |
+
json.dump(label_list, f)
|
| 279 |
+
|
| 280 |
+
console.print(f"[green]Full ONNX model saved to {output_dir}/fusion_full.onnx[/green]")
|
| 281 |
+
console.print(f"[green]Labels saved to {output_dir}/labels.json[/green]")
|
| 282 |
+
console.print(f"[green]Model has {len(label_list)} output classes.[/green]")
|
| 283 |
+
|
| 284 |
+
def infer_fusion_onnx(image_path, symptoms, model_dir=None):
|
| 285 |
+
"""Run inference using the full ONNX pipeline. No PyTorch needed beyond preprocessing.
|
| 286 |
+
|
| 287 |
+
Searches for models in this order:
|
| 288 |
+
1. checkpoints/onnx_full/ (trained full pipeline)
|
| 289 |
+
2. models/default/ (default shipped classifier)
|
| 290 |
+
"""
|
| 291 |
+
import json
|
| 292 |
+
import numpy as np
|
| 293 |
+
import onnxruntime as ort
|
| 294 |
+
from transformers import CLIPProcessor, AutoTokenizer
|
| 295 |
+
|
| 296 |
+
# Find the best available ONNX model + labels
|
| 297 |
+
candidates = [
|
| 298 |
+
(model_dir, "fusion_full.onnx", "labels.json"),
|
| 299 |
+
(ONNX_FULL_DIR, "fusion_full.onnx", "labels.json"),
|
| 300 |
+
(DEFAULT_MODEL_DIR, "fusion_classifier.onnx", "labels.json"),
|
| 301 |
+
]
|
| 302 |
+
|
| 303 |
+
onnx_path = None
|
| 304 |
+
labels_path = None
|
| 305 |
+
for d, m, l in candidates:
|
| 306 |
+
if d is None:
|
| 307 |
+
continue
|
| 308 |
+
mp = os.path.join(d, m)
|
| 309 |
+
lp = os.path.join(d, l)
|
| 310 |
+
if os.path.exists(mp) and os.path.exists(lp):
|
| 311 |
+
onnx_path = mp
|
| 312 |
+
labels_path = lp
|
| 313 |
+
break
|
| 314 |
+
|
| 315 |
+
if onnx_path is None:
|
| 316 |
+
return None, "No ONNX model found. Run 'python setup_default.py' or 'python quantization.py --mode export-full'."
|
| 317 |
+
|
| 318 |
+
with open(labels_path) as f:
|
| 319 |
+
label_list = json.load(f)
|
| 320 |
+
|
| 321 |
+
clip_processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
|
| 322 |
+
tokenizer = AutoTokenizer.from_pretrained("emilyalsentzer/Bio_ClinicalBERT")
|
| 323 |
+
|
| 324 |
+
image = Image.open(image_path).convert("RGB")
|
| 325 |
+
img_inputs = clip_processor(images=image, return_tensors="np")
|
| 326 |
+
pixel_values = img_inputs["pixel_values"].astype(np.float32)
|
| 327 |
+
|
| 328 |
+
tok_inputs = tokenizer(symptoms, return_tensors="np", padding="max_length", truncation=True, max_length=64)
|
| 329 |
+
input_ids = tok_inputs["input_ids"].astype(np.int64)
|
| 330 |
+
attention_mask = tok_inputs["attention_mask"].astype(np.int64)
|
| 331 |
+
|
| 332 |
+
session = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
|
| 333 |
+
logits = session.run(None, {
|
| 334 |
+
"pixel_values": pixel_values,
|
| 335 |
+
"input_ids": input_ids,
|
| 336 |
+
"attention_mask": attention_mask,
|
| 337 |
+
})[0]
|
| 338 |
+
|
| 339 |
+
probs = np.exp(logits - logits.max(axis=-1, keepdims=True))
|
| 340 |
+
probs = probs / probs.sum(axis=-1, keepdims=True)
|
| 341 |
+
predicted = np.argmax(probs, axis=-1)
|
| 342 |
+
confidence = float(probs[0, predicted[0]])
|
| 343 |
+
|
| 344 |
+
return label_list[predicted[0]], confidence
|
| 345 |
+
|
| 346 |
+
# ── Legacy Quantization (kept for backward compat) ──────────
|
| 347 |
+
|
| 348 |
+
def quantize_blip(output_dir=None):
|
| 349 |
+
from rich.console import Console
|
| 350 |
+
console = Console()
|
| 351 |
+
console.print("[yellow]BLIP ONNX quantization requires a newer version of optimum.[/yellow]")
|
| 352 |
+
console.print("[yellow]Run: pip install --upgrade optimum[/yellow]")
|
| 353 |
+
console.print("[yellow]The system uses PyTorch automatically until then.[/yellow]")
|
| 354 |
+
|
| 355 |
+
def quantize_fusion(output_dir=None):
|
| 356 |
+
if output_dir is None:
|
| 357 |
+
output_dir = os.path.join(CHECKPOINT_DIR, "onnx")
|
| 358 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 359 |
+
|
| 360 |
+
if not os.path.exists(CHECKPOINT_PATH):
|
| 361 |
+
print("No fusion checkpoint found. Train first.")
|
| 362 |
+
return
|
| 363 |
+
|
| 364 |
+
from rich.console import Console
|
| 365 |
+
console = Console()
|
| 366 |
+
|
| 367 |
+
try:
|
| 368 |
+
import onnxscript
|
| 369 |
+
except ImportError:
|
| 370 |
+
console.print("[yellow]'onnxscript' is required for ONNX export.[/yellow]")
|
| 371 |
+
import questionary
|
| 372 |
+
if questionary.confirm("Install onnxscript now?", default=True).ask():
|
| 373 |
+
import subprocess, sys
|
| 374 |
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "onnxscript"])
|
| 375 |
+
else:
|
| 376 |
+
console.print("[yellow]Skipped. The system works fine without ONNX export.[/yellow]")
|
| 377 |
+
return
|
| 378 |
+
|
| 379 |
+
console.print("[cyan]The fusion model uses frozen CLIP + BERT encoders.[/cyan]")
|
| 380 |
+
console.print("[cyan]Exporting the classifier head only to ONNX (encoders stay in PyTorch).[/cyan]")
|
| 381 |
+
|
| 382 |
+
from training import DiagnosisFusionModel, load_label_list
|
| 383 |
+
|
| 384 |
+
label_list = load_label_list()
|
| 385 |
+
checkpoint = torch.load(CHECKPOINT_PATH, weights_only=False)
|
| 386 |
+
model = DiagnosisFusionModel(num_conditions=len(label_list))
|
| 387 |
+
model.classifier.load_state_dict(checkpoint["model_state"])
|
| 388 |
+
model.eval()
|
| 389 |
+
|
| 390 |
+
dummy = torch.randn(1, 512 + 768)
|
| 391 |
+
torch.onnx.export(
|
| 392 |
+
model.classifier,
|
| 393 |
+
dummy,
|
| 394 |
+
os.path.join(output_dir, "fusion_classifier.onnx"),
|
| 395 |
+
input_names=["features"],
|
| 396 |
+
output_names=["logits"],
|
| 397 |
+
opset_version=14,
|
| 398 |
+
)
|
| 399 |
+
console.print(f"[green] ONNX classifier saved to {output_dir}[/green]")
|
push_to_hub.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Push this project to the Hugging Face Hub.
|
| 2 |
+
|
| 3 |
+
A bare `hf upload <repo> .` would push .venv (1.4 GB), 9,688 dataset images and
|
| 4 |
+
__pycache__ to the Hub -- `hf upload` does not read .gitignore. This script runs
|
| 5 |
+
the upload with the right excludes and publishes MODEL_CARD.md as the Hub's
|
| 6 |
+
README.md, leaving the GitHub README.md alone.
|
| 7 |
+
|
| 8 |
+
python push_to_hub.py # dry run: list what would be uploaded
|
| 9 |
+
python push_to_hub.py --push # do it
|
| 10 |
+
python push_to_hub.py --push --data # include ./data (~181 MB, 9.7k images)
|
| 11 |
+
python push_to_hub.py --push --create-pr
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import os
|
| 16 |
+
import subprocess
|
| 17 |
+
import sys
|
| 18 |
+
|
| 19 |
+
REPO_ID = "GAD-Research-Lab/MedicalAI-Light-Weight"
|
| 20 |
+
ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 21 |
+
|
| 22 |
+
# `hf upload` matches these with fnmatch against repo-relative POSIX paths.
|
| 23 |
+
EXCLUDE = [
|
| 24 |
+
".venv/*",
|
| 25 |
+
".git/*",
|
| 26 |
+
"**/__pycache__/*",
|
| 27 |
+
"*.pyc",
|
| 28 |
+
"build/*",
|
| 29 |
+
"dist/*",
|
| 30 |
+
"*.spec",
|
| 31 |
+
"update_config.json",
|
| 32 |
+
"results.csv",
|
| 33 |
+
"analyzing_images_for_ai.md", # local scratch notes (gitignored)
|
| 34 |
+
"blip-xray-finetuned/xray_blip.pth", # {'epoch': N} stub, no weights -- misleading on the Hub
|
| 35 |
+
"MODEL_CARD.md", # uploaded separately, as README.md
|
| 36 |
+
"README.md", # GitHub README; the model card takes its place on the Hub
|
| 37 |
+
]
|
| 38 |
+
DATA_EXCLUDE = ["data/*"]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def hf_executable():
|
| 42 |
+
"""Prefer the `hf` next to the running interpreter, so a venv run stays in its venv."""
|
| 43 |
+
bindir = os.path.dirname(sys.executable)
|
| 44 |
+
for candidate in (os.path.join(bindir, "hf.exe"), os.path.join(bindir, "hf")):
|
| 45 |
+
if os.path.exists(candidate):
|
| 46 |
+
return candidate
|
| 47 |
+
return "hf"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def files_to_upload(exclude):
|
| 51 |
+
"""Mirror hf upload's own filtering so the dry run is accurate."""
|
| 52 |
+
from huggingface_hub.utils import filter_repo_objects
|
| 53 |
+
|
| 54 |
+
paths = []
|
| 55 |
+
for dirpath, dirnames, filenames in os.walk(ROOT):
|
| 56 |
+
dirnames[:] = [d for d in dirnames if d not in (".git", ".venv", "__pycache__")]
|
| 57 |
+
for name in filenames:
|
| 58 |
+
full = os.path.join(dirpath, name)
|
| 59 |
+
paths.append(os.path.relpath(full, ROOT).replace(os.sep, "/"))
|
| 60 |
+
return sorted(filter_repo_objects(paths, allow_patterns=None, ignore_patterns=exclude))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def main():
|
| 64 |
+
ap = argparse.ArgumentParser()
|
| 65 |
+
ap.add_argument("--push", action="store_true", help="Actually upload (default is a dry run)")
|
| 66 |
+
ap.add_argument("--data", action="store_true", help="Include ./data (~181 MB of images)")
|
| 67 |
+
ap.add_argument("--create-pr", action="store_true", help="Open a PR instead of committing to main")
|
| 68 |
+
ap.add_argument("--private", action="store_true", help="Create the repo private if it does not exist")
|
| 69 |
+
ap.add_argument("--repo-id", default=REPO_ID)
|
| 70 |
+
ap.add_argument("--message", default="Upload models, ONNX exports and application code")
|
| 71 |
+
args = ap.parse_args()
|
| 72 |
+
|
| 73 |
+
exclude = list(EXCLUDE) + ([] if args.data else DATA_EXCLUDE)
|
| 74 |
+
|
| 75 |
+
files = files_to_upload(exclude)
|
| 76 |
+
total = sum(os.path.getsize(os.path.join(ROOT, f)) for f in files)
|
| 77 |
+
print(f"{len(files)} files, {total / 1e9:.2f} GB -> {args.repo_id}\n")
|
| 78 |
+
for f in files:
|
| 79 |
+
size = os.path.getsize(os.path.join(ROOT, f))
|
| 80 |
+
print(f" {size / 1e6:>9.2f} MB {f}" if size > 1e6 else f" {'':>9} {f}")
|
| 81 |
+
|
| 82 |
+
if not args.push:
|
| 83 |
+
print("\nDry run. Re-run with --push to upload.")
|
| 84 |
+
return 0
|
| 85 |
+
|
| 86 |
+
hf = hf_executable()
|
| 87 |
+
common = ["--repo-type", "model"]
|
| 88 |
+
if args.create_pr:
|
| 89 |
+
common.append("--create-pr")
|
| 90 |
+
if args.private:
|
| 91 |
+
common.append("--private")
|
| 92 |
+
|
| 93 |
+
# 1. Model card first, so the repo is never briefly published without one.
|
| 94 |
+
print("\n-> Uploading MODEL_CARD.md as README.md")
|
| 95 |
+
card = subprocess.run(
|
| 96 |
+
[hf, "upload", args.repo_id, os.path.join(ROOT, "MODEL_CARD.md"), "README.md",
|
| 97 |
+
"--commit-message", "Add model card", *common],
|
| 98 |
+
cwd=ROOT,
|
| 99 |
+
)
|
| 100 |
+
if card.returncode != 0:
|
| 101 |
+
print("Model card upload failed; stopping before the bulk upload.", file=sys.stderr)
|
| 102 |
+
return card.returncode
|
| 103 |
+
|
| 104 |
+
# 2. Everything else.
|
| 105 |
+
print("\n-> Uploading project files")
|
| 106 |
+
bulk = subprocess.run(
|
| 107 |
+
[hf, "upload", args.repo_id, ".", "--commit-message", args.message,
|
| 108 |
+
"--exclude", *exclude, *common],
|
| 109 |
+
cwd=ROOT,
|
| 110 |
+
)
|
| 111 |
+
if bulk.returncode != 0:
|
| 112 |
+
print("Bulk upload FAILED.", file=sys.stderr)
|
| 113 |
+
return bulk.returncode
|
| 114 |
+
|
| 115 |
+
# Never trust the exit code alone -- confirm against the Hub. A 403 on the LFS
|
| 116 |
+
# endpoint can still leave small files committed, which looks like success.
|
| 117 |
+
from huggingface_hub import HfApi
|
| 118 |
+
|
| 119 |
+
remote = set(HfApi().list_repo_files(args.repo_id))
|
| 120 |
+
missing = [f for f in files if f not in remote and f != "MODEL_CARD.md"]
|
| 121 |
+
if missing:
|
| 122 |
+
print(f"\n{len(missing)} file(s) did NOT land on the Hub:", file=sys.stderr)
|
| 123 |
+
for f in missing:
|
| 124 |
+
print(f" {f}", file=sys.stderr)
|
| 125 |
+
return 1
|
| 126 |
+
|
| 127 |
+
print(f"\nVerified {len(files)} files: https://huggingface.co/{args.repo_id}")
|
| 128 |
+
return 0
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
if __name__ == "__main__":
|
| 132 |
+
sys.exit(main())
|
quantization.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import subprocess
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from optimize import (
|
| 9 |
+
clear_memory,
|
| 10 |
+
get_available_models,
|
| 11 |
+
get_device,
|
| 12 |
+
get_memory_usage,
|
| 13 |
+
infer_blip,
|
| 14 |
+
infer_fusion,
|
| 15 |
+
infer_fusion_onnx,
|
| 16 |
+
set_cpu_threads,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
DEFAULT_MODEL_DIR = "./models/default"
|
| 20 |
+
CHECKPOINT_PATH = "./checkpoints/fusion_model.pth"
|
| 21 |
+
ONNX_FULL_DIR = "./checkpoints/onnx_full"
|
| 22 |
+
|
| 23 |
+
def _ensure_default_models():
|
| 24 |
+
if os.path.exists(os.path.join(DEFAULT_MODEL_DIR, "fusion_classifier.onnx")):
|
| 25 |
+
return
|
| 26 |
+
if os.path.exists(CHECKPOINT_PATH):
|
| 27 |
+
return
|
| 28 |
+
print("No models found. Generating default models...")
|
| 29 |
+
subprocess.check_call(
|
| 30 |
+
[sys.executable, "setup_default.py"],
|
| 31 |
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
def _get_model_source():
|
| 35 |
+
models = get_available_models()
|
| 36 |
+
if models["onnx_full_pipeline"]:
|
| 37 |
+
return "ONNX (full pipeline)"
|
| 38 |
+
if models["trained_pytorch"]:
|
| 39 |
+
return "Trained PyTorch"
|
| 40 |
+
if models["default_classifier"]:
|
| 41 |
+
return "Default (random weights)"
|
| 42 |
+
return "NOT AVAILABLE"
|
| 43 |
+
|
| 44 |
+
# ── FastAPI App (lazy-loaded) ────────────────────────────────
|
| 45 |
+
|
| 46 |
+
app = None
|
| 47 |
+
_executor = None
|
| 48 |
+
|
| 49 |
+
def _get_app():
|
| 50 |
+
global app, _executor
|
| 51 |
+
if app is not None:
|
| 52 |
+
return app
|
| 53 |
+
|
| 54 |
+
import asyncio
|
| 55 |
+
import tempfile
|
| 56 |
+
import time
|
| 57 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 58 |
+
from contextlib import asynccontextmanager
|
| 59 |
+
from typing import Optional
|
| 60 |
+
|
| 61 |
+
from fastapi import FastAPI, File, Form, UploadFile, HTTPException
|
| 62 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 63 |
+
from pydantic import BaseModel
|
| 64 |
+
|
| 65 |
+
_executor = ThreadPoolExecutor(max_workers=2)
|
| 66 |
+
|
| 67 |
+
@asynccontextmanager
|
| 68 |
+
async def _app_lifespan(fapp: FastAPI):
|
| 69 |
+
set_cpu_threads()
|
| 70 |
+
_ensure_default_models()
|
| 71 |
+
yield
|
| 72 |
+
clear_memory()
|
| 73 |
+
|
| 74 |
+
a = FastAPI(
|
| 75 |
+
title="MedicalAI - Light Weight API",
|
| 76 |
+
description="REST API for chest X-ray analysis. Upload images for radiology captions or symptom-based diagnosis.",
|
| 77 |
+
version="1.0.0",
|
| 78 |
+
lifespan=_app_lifespan,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
a.add_middleware(
|
| 82 |
+
CORSMiddleware,
|
| 83 |
+
allow_origins=["*"],
|
| 84 |
+
allow_credentials=True,
|
| 85 |
+
allow_methods=["*"],
|
| 86 |
+
allow_headers=["*"],
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
class HealthResponse(BaseModel):
|
| 90 |
+
model_config = {"protected_namespaces": ()}
|
| 91 |
+
status: str
|
| 92 |
+
device: str
|
| 93 |
+
model_source: str
|
| 94 |
+
memory: dict
|
| 95 |
+
models: dict
|
| 96 |
+
|
| 97 |
+
class VisionResponse(BaseModel):
|
| 98 |
+
caption: str
|
| 99 |
+
inference_time_ms: float
|
| 100 |
+
|
| 101 |
+
class SymptomResponse(BaseModel):
|
| 102 |
+
diagnosis: str
|
| 103 |
+
confidence: float
|
| 104 |
+
inference_time_ms: float
|
| 105 |
+
|
| 106 |
+
@a.get("/health", response_model=HealthResponse)
|
| 107 |
+
@a.get("/api/health", response_model=HealthResponse)
|
| 108 |
+
async def health():
|
| 109 |
+
models = get_available_models()
|
| 110 |
+
return HealthResponse(
|
| 111 |
+
status="ok",
|
| 112 |
+
device=get_device().upper(),
|
| 113 |
+
model_source=_get_model_source(),
|
| 114 |
+
memory=get_memory_usage(),
|
| 115 |
+
models=models,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
@a.post("/api/vision", response_model=VisionResponse)
|
| 119 |
+
async def analyze_vision(file: UploadFile = File(...)):
|
| 120 |
+
ext = Path(file.filename).suffix if file.filename else ".jpg"
|
| 121 |
+
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
|
| 122 |
+
content = await file.read()
|
| 123 |
+
f.write(content)
|
| 124 |
+
path = f.name
|
| 125 |
+
loop = asyncio.get_event_loop()
|
| 126 |
+
try:
|
| 127 |
+
t0 = time.perf_counter()
|
| 128 |
+
caption = await loop.run_in_executor(_executor, infer_blip, path, False)
|
| 129 |
+
elapsed = (time.perf_counter() - t0) * 1000
|
| 130 |
+
return VisionResponse(caption=caption, inference_time_ms=round(elapsed, 1))
|
| 131 |
+
except Exception as e:
|
| 132 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 133 |
+
finally:
|
| 134 |
+
os.unlink(path)
|
| 135 |
+
await loop.run_in_executor(None, clear_memory)
|
| 136 |
+
|
| 137 |
+
@a.post("/api/symptom-check", response_model=SymptomResponse)
|
| 138 |
+
async def analyze_symptom(
|
| 139 |
+
file: UploadFile = File(...),
|
| 140 |
+
symptoms: str = Form("No symptoms provided"),
|
| 141 |
+
use_onnx: Optional[bool] = None,
|
| 142 |
+
):
|
| 143 |
+
ext = Path(file.filename).suffix if file.filename else ".jpg"
|
| 144 |
+
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
|
| 145 |
+
content = await file.read()
|
| 146 |
+
f.write(content)
|
| 147 |
+
path = f.name
|
| 148 |
+
loop = asyncio.get_event_loop()
|
| 149 |
+
try:
|
| 150 |
+
if use_onnx is None:
|
| 151 |
+
models = get_available_models()
|
| 152 |
+
use_onnx = models.get("onnx_full_pipeline", False)
|
| 153 |
+
|
| 154 |
+
t0 = time.perf_counter()
|
| 155 |
+
if use_onnx:
|
| 156 |
+
diagnosis, confidence = await loop.run_in_executor(
|
| 157 |
+
_executor, infer_fusion_onnx, path, symptoms
|
| 158 |
+
)
|
| 159 |
+
else:
|
| 160 |
+
diagnosis, confidence = await loop.run_in_executor(
|
| 161 |
+
_executor, infer_fusion, path, symptoms
|
| 162 |
+
)
|
| 163 |
+
elapsed = (time.perf_counter() - t0) * 1000
|
| 164 |
+
|
| 165 |
+
if diagnosis is None:
|
| 166 |
+
raise HTTPException(status_code=500, detail=confidence)
|
| 167 |
+
|
| 168 |
+
return SymptomResponse(
|
| 169 |
+
diagnosis=diagnosis,
|
| 170 |
+
confidence=round(confidence, 4),
|
| 171 |
+
inference_time_ms=round(elapsed, 1),
|
| 172 |
+
)
|
| 173 |
+
except HTTPException:
|
| 174 |
+
raise
|
| 175 |
+
except Exception as e:
|
| 176 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 177 |
+
finally:
|
| 178 |
+
os.unlink(path)
|
| 179 |
+
await loop.run_in_executor(None, clear_memory)
|
| 180 |
+
|
| 181 |
+
@a.get("/api/models")
|
| 182 |
+
async def list_models():
|
| 183 |
+
return get_available_models()
|
| 184 |
+
|
| 185 |
+
app = a
|
| 186 |
+
return app
|
| 187 |
+
|
| 188 |
+
# ── Original CLI Modes ──────────────────────────────────────
|
| 189 |
+
|
| 190 |
+
def quantize_fusion():
|
| 191 |
+
from optimize import quantize_fusion as _do
|
| 192 |
+
_do()
|
| 193 |
+
|
| 194 |
+
def export_full():
|
| 195 |
+
from optimize import export_full_fusion_onnx
|
| 196 |
+
export_full_fusion_onnx()
|
| 197 |
+
|
| 198 |
+
def optimize_all():
|
| 199 |
+
from rich.console import Console
|
| 200 |
+
console = Console()
|
| 201 |
+
console.print("[bold cyan]Full Optimization Pipeline[/bold cyan]")
|
| 202 |
+
console.print()
|
| 203 |
+
console.print("[cyan]Quantizing fusion model...[/cyan]")
|
| 204 |
+
from optimize import quantize_fusion as qf
|
| 205 |
+
qf()
|
| 206 |
+
console.print()
|
| 207 |
+
console.print("[cyan]Exporting full ONNX pipeline...[/cyan]")
|
| 208 |
+
from optimize import export_full_fusion_onnx as ef
|
| 209 |
+
ef()
|
| 210 |
+
console.print()
|
| 211 |
+
console.print("[green]Optimization complete![/green]")
|
| 212 |
+
console.print(" Fusion classifier: ./checkpoints/onnx/fusion_classifier.onnx")
|
| 213 |
+
console.print(" Full pipeline: ./checkpoints/onnx_full/fusion_full.onnx")
|
| 214 |
+
|
| 215 |
+
def set_threads():
|
| 216 |
+
from optimize import set_cpu_threads as sct, get_memory_usage as gmu
|
| 217 |
+
n = sct()
|
| 218 |
+
mem = gmu()
|
| 219 |
+
print(f"CPU threads set to {n}")
|
| 220 |
+
print(f"Current RAM: {mem['rss_mb']:.0f} MB")
|
| 221 |
+
|
| 222 |
+
def show_status():
|
| 223 |
+
import torch
|
| 224 |
+
mem = get_memory_usage()
|
| 225 |
+
print(f"Device: {get_device().upper()}")
|
| 226 |
+
print(f"FP16 mode: {'ON' if get_device() in ('cuda', 'mps') else 'OFF'}")
|
| 227 |
+
print(f"Process RAM: {mem['rss_mb']:.0f} MB")
|
| 228 |
+
print(f"Torch threads: {torch.get_num_threads()}")
|
| 229 |
+
fusion_onnx = os.path.exists("./checkpoints/onnx/fusion_classifier.onnx")
|
| 230 |
+
fusion_pt = os.path.exists(CHECKPOINT_PATH)
|
| 231 |
+
fusion_full = os.path.exists(ONNX_FULL_DIR + "/fusion_full.onnx")
|
| 232 |
+
print(f"Fusion ONNX (classifier): {'yes' if fusion_onnx else 'no'}")
|
| 233 |
+
print(f"Fusion ONNX (full): {'yes' if fusion_full else 'no'}")
|
| 234 |
+
print(f"Fusion .pth: {'yes' if fusion_pt else 'no'}")
|
| 235 |
+
print(f"BLIP model: {'fine-tuned' if os.path.exists('./blip-xray-finetuned') else 'stock (no fine-tune)'}")
|
| 236 |
+
|
| 237 |
+
def explain():
|
| 238 |
+
from rich.console import Console
|
| 239 |
+
from rich.table import Table
|
| 240 |
+
from rich.panel import Panel
|
| 241 |
+
console = Console()
|
| 242 |
+
console.print(Panel.fit("[bold cyan]Model Optimization - How It Works[/bold cyan]"))
|
| 243 |
+
console.print()
|
| 244 |
+
console.print("[bold]Deployment options:[/bold]")
|
| 245 |
+
console.print()
|
| 246 |
+
t = Table(title="Deployment Options")
|
| 247 |
+
t.add_column("Option", style="cyan", width=22)
|
| 248 |
+
t.add_column("What it is", style="white")
|
| 249 |
+
t.add_column("Best for", style="green")
|
| 250 |
+
t.add_row("PyTorch (default)", "Full model in PyTorch.", "Development, GPU users")
|
| 251 |
+
t.add_row("ONNX Classifier", "Exports classifier head only.", "Minor CPU speedup")
|
| 252 |
+
t.add_row("ONNX Full Pipeline", "Exports entire pipeline to ONNX.", "Production, no-PyTorch")
|
| 253 |
+
console.print(t)
|
| 254 |
+
console.print()
|
| 255 |
+
t2 = Table(title="Fusion Model Components")
|
| 256 |
+
t2.add_column("Part", style="cyan", width=18)
|
| 257 |
+
t2.add_column("Role", style="white")
|
| 258 |
+
t2.add_column("Size", style="green")
|
| 259 |
+
t2.add_row("CLIP encoder", "Image -> 512 features", "~600 MB")
|
| 260 |
+
t2.add_row("Bio_ClinicalBERT", "Symptoms -> 768 features", "~400 MB")
|
| 261 |
+
t2.add_row("Classifier head", "1280 -> 256 -> N classes", "~0.5 MB")
|
| 262 |
+
console.print(t2)
|
| 263 |
+
|
| 264 |
+
# ── API Server Mode ─────────────────────────────────────────
|
| 265 |
+
|
| 266 |
+
def serve_api(host="127.0.0.1", port=8000, reload=False):
|
| 267 |
+
global app
|
| 268 |
+
_get_app()
|
| 269 |
+
import uvicorn
|
| 270 |
+
print(f"MedicalAI API Server starting on http://{host}:{port}")
|
| 271 |
+
print(f"Device: {get_device().upper()}")
|
| 272 |
+
print(f"Docs: http://{host}:{port}/docs")
|
| 273 |
+
print(f"Health: http://{host}:{port}/health")
|
| 274 |
+
print()
|
| 275 |
+
print("Your website can connect to this API using the endpoints above.")
|
| 276 |
+
uvicorn.run("quantization:app", host=host, port=port, reload=reload)
|
| 277 |
+
|
| 278 |
+
# ── Main ─────────────────────────────────────────────────────
|
| 279 |
+
|
| 280 |
+
if __name__ == "__main__":
|
| 281 |
+
parser = argparse.ArgumentParser(description="MedicalAI - Export & Web API server")
|
| 282 |
+
parser.add_argument("--mode",
|
| 283 |
+
choices=[
|
| 284 |
+
"quantize-fusion", "export-full", "optimize-all",
|
| 285 |
+
"set-threads", "status", "explain",
|
| 286 |
+
"serve-api",
|
| 287 |
+
],
|
| 288 |
+
default="status")
|
| 289 |
+
parser.add_argument("--host", default="127.0.0.1", help="Host for serve-api (default: 127.0.0.1)")
|
| 290 |
+
parser.add_argument("--port", type=int, default=8000, help="Port for serve-api (default: 8000)")
|
| 291 |
+
parser.add_argument("--reload", action="store_true", help="Auto-reload for development")
|
| 292 |
+
args = parser.parse_args()
|
| 293 |
+
|
| 294 |
+
if args.mode == "quantize-fusion":
|
| 295 |
+
quantize_fusion()
|
| 296 |
+
elif args.mode == "export-full":
|
| 297 |
+
export_full()
|
| 298 |
+
elif args.mode == "optimize-all":
|
| 299 |
+
optimize_all()
|
| 300 |
+
elif args.mode == "set-threads":
|
| 301 |
+
set_threads()
|
| 302 |
+
elif args.mode == "explain":
|
| 303 |
+
explain()
|
| 304 |
+
elif args.mode == "serve-api":
|
| 305 |
+
serve_api(host=args.host, port=args.port, reload=args.reload)
|
| 306 |
+
else:
|
| 307 |
+
show_status()
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.0.0
|
| 2 |
+
transformers>=4.36.0
|
| 3 |
+
datasets>=2.14.0
|
| 4 |
+
pillow>=10.0.0
|
| 5 |
+
rich>=13.0.0
|
| 6 |
+
questionary>=2.0.0
|
| 7 |
+
psutil>=5.9.0
|
| 8 |
+
tqdm>=4.65.0
|
| 9 |
+
optimum>=1.12.0
|
| 10 |
+
onnxscript>=0.7.0
|
| 11 |
+
|
| 12 |
+
# Optional (auto-installed on first use):
|
| 13 |
+
# opencv-python -- camera capture
|
| 14 |
+
# pydicom -- DICOM file loading
|
| 15 |
+
# nltk -- BLEU evaluation
|
| 16 |
+
# gradio -- Web UI (pip install gradio)
|
| 17 |
+
# fastapi -- REST API server (pip install "fastapi[standard]")
|
| 18 |
+
# uvicorn -- ASGI server for the API
|
run.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
import questionary
|
| 4 |
+
from rich.console import Console
|
| 5 |
+
from rich.panel import Panel
|
| 6 |
+
from rich.table import Table
|
| 7 |
+
|
| 8 |
+
import capture
|
| 9 |
+
from optimize import (
|
| 10 |
+
clear_memory,
|
| 11 |
+
get_device,
|
| 12 |
+
get_memory_usage,
|
| 13 |
+
infer_blip,
|
| 14 |
+
infer_fusion,
|
| 15 |
+
set_cpu_threads,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
console = Console()
|
| 19 |
+
|
| 20 |
+
def run_vision(image_path):
|
| 21 |
+
console.print("[cyan]Running Vision analysis...[/cyan]")
|
| 22 |
+
try:
|
| 23 |
+
caption = infer_blip(image_path)
|
| 24 |
+
except Exception as e:
|
| 25 |
+
console.print(f"[red]Error: {e}[/red]")
|
| 26 |
+
return
|
| 27 |
+
|
| 28 |
+
console.print()
|
| 29 |
+
console.print(Panel(f"[bold green]{caption}[/bold green]", title="Generated Radiology Caption"))
|
| 30 |
+
return caption
|
| 31 |
+
|
| 32 |
+
def run_symptom_check(image_path):
|
| 33 |
+
symptoms = questionary.text("Enter patient symptoms / clinical indication:").ask()
|
| 34 |
+
if not symptoms:
|
| 35 |
+
symptoms = "No symptoms provided"
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
diagnosis, confidence = infer_fusion(image_path, symptoms)
|
| 39 |
+
except Exception as e:
|
| 40 |
+
console.print(f"[red]Error: {e}[/red]")
|
| 41 |
+
return
|
| 42 |
+
|
| 43 |
+
if diagnosis is None:
|
| 44 |
+
console.print(f"[red]{confidence}[/red]")
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
table = Table(title="Diagnosis Result")
|
| 48 |
+
table.add_column("Prediction", style="cyan")
|
| 49 |
+
table.add_column("Confidence", style="green")
|
| 50 |
+
table.add_row(diagnosis, f"{confidence:.1%}")
|
| 51 |
+
console.print(table)
|
| 52 |
+
|
| 53 |
+
if confidence < 0.75:
|
| 54 |
+
console.print("[yellow]Low confidence — consider follow-up.[/yellow]")
|
| 55 |
+
|
| 56 |
+
return diagnosis, confidence
|
| 57 |
+
|
| 58 |
+
def show_status():
|
| 59 |
+
from training import info as training_info
|
| 60 |
+
|
| 61 |
+
console.print("[bold cyan]--- System Status ---[/bold cyan]")
|
| 62 |
+
mem = get_memory_usage()
|
| 63 |
+
console.print(f"Device: [green]{get_device().upper()}[/green]")
|
| 64 |
+
console.print(f"Process RAM: {mem['rss_mb']:.0f} MB")
|
| 65 |
+
console.print()
|
| 66 |
+
|
| 67 |
+
console.print("[bold cyan]--- Fusion Model Data ---[/bold cyan]")
|
| 68 |
+
training_info()
|
| 69 |
+
console.print()
|
| 70 |
+
|
| 71 |
+
cap_dir = capture.IMAGES_DIR
|
| 72 |
+
count = len(list(cap_dir.glob("*"))) if cap_dir.exists() else 0
|
| 73 |
+
console.print(f"Captured images: {count} in {cap_dir}")
|
| 74 |
+
|
| 75 |
+
def install_deps():
|
| 76 |
+
deps = []
|
| 77 |
+
try:
|
| 78 |
+
import cv2
|
| 79 |
+
except ImportError:
|
| 80 |
+
deps.append("opencv-python")
|
| 81 |
+
try:
|
| 82 |
+
import pydicom
|
| 83 |
+
except ImportError:
|
| 84 |
+
deps.append("pydicom")
|
| 85 |
+
try:
|
| 86 |
+
import nltk
|
| 87 |
+
except ImportError:
|
| 88 |
+
deps.append("nltk")
|
| 89 |
+
|
| 90 |
+
if not deps:
|
| 91 |
+
console.print("[green]All optional dependencies are already installed.[/green]")
|
| 92 |
+
return
|
| 93 |
+
|
| 94 |
+
import subprocess
|
| 95 |
+
import sys
|
| 96 |
+
console.print(f"[yellow]Installing: {' '.join(deps)}[/yellow]")
|
| 97 |
+
subprocess.check_call([sys.executable, "-m", "pip", "install", *deps])
|
| 98 |
+
console.print("[green]Done.[/green]")
|
| 99 |
+
|
| 100 |
+
def main():
|
| 101 |
+
set_cpu_threads()
|
| 102 |
+
|
| 103 |
+
console.print(Panel.fit("[bold cyan]MedicalAI - Light Weight[/bold cyan]"))
|
| 104 |
+
console.print()
|
| 105 |
+
|
| 106 |
+
while True:
|
| 107 |
+
choice = questionary.select(
|
| 108 |
+
"What would you like to do?",
|
| 109 |
+
choices=[
|
| 110 |
+
"Vision — Generate report from X-ray",
|
| 111 |
+
"Symptom Check — Diagnose from X-ray + symptoms",
|
| 112 |
+
"System Status & Data Info",
|
| 113 |
+
"Install optional deps (camera, DICOM, BLEU)",
|
| 114 |
+
"Exit",
|
| 115 |
+
],
|
| 116 |
+
pointer=">",
|
| 117 |
+
).ask()
|
| 118 |
+
|
| 119 |
+
if choice == "Exit":
|
| 120 |
+
console.print("[bold red]Exiting...[/bold red]")
|
| 121 |
+
clear_memory()
|
| 122 |
+
break
|
| 123 |
+
|
| 124 |
+
if choice == "Install optional deps (camera, DICOM, BLEU)":
|
| 125 |
+
install_deps()
|
| 126 |
+
continue
|
| 127 |
+
|
| 128 |
+
if choice == "System Status & Data Info":
|
| 129 |
+
show_status()
|
| 130 |
+
console.print()
|
| 131 |
+
continue
|
| 132 |
+
|
| 133 |
+
image_path, msg = capture.pick_image()
|
| 134 |
+
if image_path is None:
|
| 135 |
+
console.print(f"[red]{msg}[/red]")
|
| 136 |
+
continue
|
| 137 |
+
console.print(f"[dim]{msg}[/dim]")
|
| 138 |
+
|
| 139 |
+
if choice.startswith("Vision"):
|
| 140 |
+
run_vision(image_path)
|
| 141 |
+
elif choice.startswith("Symptom Check"):
|
| 142 |
+
run_symptom_check(image_path)
|
| 143 |
+
|
| 144 |
+
clear_memory()
|
| 145 |
+
console.print()
|
| 146 |
+
again = questionary.confirm("Do another?").ask()
|
| 147 |
+
if not again:
|
| 148 |
+
break
|
| 149 |
+
|
| 150 |
+
clear_memory()
|
| 151 |
+
|
| 152 |
+
if __name__ == "__main__":
|
| 153 |
+
main()
|
setup_default.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generate default ONNX models so the app works immediately after install.
|
| 3 |
+
|
| 4 |
+
Run: python setup_default.py
|
| 5 |
+
|
| 6 |
+
Creates:
|
| 7 |
+
- models/default/labels.json (15 standard NIH classes)
|
| 8 |
+
- models/default/fusion_classifier.onnx (small MLP with random weights)
|
| 9 |
+
- models/default/fusion_full.onnx (if full pipeline export possible)
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import shutil
|
| 14 |
+
|
| 15 |
+
DEFAULT_DIR = os.path.join("models", "default")
|
| 16 |
+
NIH_LABELS = [
|
| 17 |
+
"No Finding", "Atelectasis", "Cardiomegaly", "Effusion", "Infiltration",
|
| 18 |
+
"Mass", "Nodule", "Pneumonia", "Pneumothorax", "Consolidation",
|
| 19 |
+
"Edema", "Emphysema", "Fibrosis", "Pleural_Thickening", "Hernia",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _ensure_dir(path):
|
| 24 |
+
os.makedirs(path, exist_ok=True)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _export_classifier_onnx():
|
| 28 |
+
"""Export a minimal ONNX classifier head (random weights, correct shape)."""
|
| 29 |
+
import torch
|
| 30 |
+
import torch.nn as nn
|
| 31 |
+
|
| 32 |
+
classifier = nn.Sequential(
|
| 33 |
+
nn.Linear(512 + 768, 256),
|
| 34 |
+
nn.ReLU(),
|
| 35 |
+
nn.Dropout(0.2),
|
| 36 |
+
nn.Linear(256, len(NIH_LABELS)),
|
| 37 |
+
)
|
| 38 |
+
classifier.eval()
|
| 39 |
+
|
| 40 |
+
dummy = torch.randn(1, 512 + 768)
|
| 41 |
+
onnx_path = os.path.join(DEFAULT_DIR, "fusion_classifier.onnx")
|
| 42 |
+
torch.onnx.export(
|
| 43 |
+
classifier,
|
| 44 |
+
dummy,
|
| 45 |
+
onnx_path,
|
| 46 |
+
input_names=["features"],
|
| 47 |
+
output_names=["logits"],
|
| 48 |
+
opset_version=14,
|
| 49 |
+
)
|
| 50 |
+
return onnx_path
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _export_full_onnx():
|
| 54 |
+
"""Try to export a full-pipeline ONNX using stock CLIP + BERT encoders.
|
| 55 |
+
|
| 56 |
+
This requires transformers to be installed. The full model is large
|
| 57 |
+
(~1 GB) but runs standalone with ONNX Runtime (no torch).
|
| 58 |
+
"""
|
| 59 |
+
try:
|
| 60 |
+
import torch
|
| 61 |
+
import torch.nn as nn
|
| 62 |
+
from transformers import CLIPModel, AutoModel
|
| 63 |
+
|
| 64 |
+
class _MinimalPipeline(nn.Module):
|
| 65 |
+
def __init__(self, num_classes):
|
| 66 |
+
super().__init__()
|
| 67 |
+
clip = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
|
| 68 |
+
bert = AutoModel.from_pretrained("emilyalsentzer/Bio_ClinicalBERT")
|
| 69 |
+
self.vision_encoder = clip.vision_model
|
| 70 |
+
self.visual_projection = clip.visual_projection
|
| 71 |
+
self.text_encoder = bert
|
| 72 |
+
self.text_pooler = bert.pooler
|
| 73 |
+
self.classifier = nn.Sequential(
|
| 74 |
+
nn.Linear(512 + 768, 256),
|
| 75 |
+
nn.ReLU(),
|
| 76 |
+
nn.Dropout(0.2),
|
| 77 |
+
nn.Linear(256, num_classes),
|
| 78 |
+
)
|
| 79 |
+
self.classifier.eval()
|
| 80 |
+
|
| 81 |
+
def forward(self, pixel_values, input_ids, attention_mask):
|
| 82 |
+
v_out = self.vision_encoder(pixel_values)
|
| 83 |
+
v_feat = self.visual_projection(v_out.pooler_output)
|
| 84 |
+
v_feat = v_feat / v_feat.norm(dim=-1, keepdim=True)
|
| 85 |
+
|
| 86 |
+
t_out = self.text_encoder(input_ids, attention_mask=attention_mask)
|
| 87 |
+
t_feat = self.text_pooler(t_out.last_hidden_state[:, 0, :])
|
| 88 |
+
|
| 89 |
+
combined = torch.cat([v_feat, t_feat], dim=-1)
|
| 90 |
+
return self.classifier(combined)
|
| 91 |
+
|
| 92 |
+
model = _MinimalPipeline(len(NIH_LABELS)).eval()
|
| 93 |
+
dummy_pixel = torch.randn(1, 3, 224, 224)
|
| 94 |
+
dummy_ids = torch.randint(0, 100, (1, 64), dtype=torch.long)
|
| 95 |
+
dummy_mask = torch.ones(1, 64, dtype=torch.long)
|
| 96 |
+
|
| 97 |
+
onnx_path = os.path.join(DEFAULT_DIR, "fusion_full.onnx")
|
| 98 |
+
torch.onnx.export(
|
| 99 |
+
model,
|
| 100 |
+
(dummy_pixel, dummy_ids, dummy_mask),
|
| 101 |
+
onnx_path,
|
| 102 |
+
input_names=["pixel_values", "input_ids", "attention_mask"],
|
| 103 |
+
output_names=["logits"],
|
| 104 |
+
opset_version=14,
|
| 105 |
+
dynamic_axes={
|
| 106 |
+
"input_ids": {0: "batch_size", 1: "seq_len"},
|
| 107 |
+
"attention_mask": {0: "batch_size", 1: "seq_len"},
|
| 108 |
+
"pixel_values": {0: "batch_size"},
|
| 109 |
+
"logits": {0: "batch_size"},
|
| 110 |
+
},
|
| 111 |
+
)
|
| 112 |
+
return onnx_path
|
| 113 |
+
except Exception as e:
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _save_labels():
|
| 118 |
+
path = os.path.join(DEFAULT_DIR, "labels.json")
|
| 119 |
+
with open(path, "w") as f:
|
| 120 |
+
json.dump(NIH_LABELS, f)
|
| 121 |
+
return path
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _save_symptoms():
|
| 125 |
+
"""Save default symptom prompts for the Vision model."""
|
| 126 |
+
path = os.path.join(DEFAULT_DIR, "symptoms.txt")
|
| 127 |
+
templates = [
|
| 128 |
+
"What abnormality is present in this chest X-ray?",
|
| 129 |
+
"Patient presents with shortness of breath and cough.",
|
| 130 |
+
"Fever and productive cough for 5 days.",
|
| 131 |
+
"Chest pain and dyspnea on exertion.",
|
| 132 |
+
"Routine pre-operative chest X-ray.",
|
| 133 |
+
"Trauma patient. Evaluate for pneumothorax or fractures.",
|
| 134 |
+
"Patient with history of smoking. Evaluate for lung pathology.",
|
| 135 |
+
"Immunocompromised patient with fever.",
|
| 136 |
+
]
|
| 137 |
+
with open(path, "w") as f:
|
| 138 |
+
f.write("\n".join(templates))
|
| 139 |
+
return path
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def main():
|
| 143 |
+
from rich.console import Console
|
| 144 |
+
console = Console()
|
| 145 |
+
|
| 146 |
+
_ensure_dir(DEFAULT_DIR)
|
| 147 |
+
|
| 148 |
+
# Remove old default files
|
| 149 |
+
for f in os.listdir(DEFAULT_DIR):
|
| 150 |
+
fp = os.path.join(DEFAULT_DIR, f)
|
| 151 |
+
try:
|
| 152 |
+
if os.path.isfile(fp):
|
| 153 |
+
os.remove(fp)
|
| 154 |
+
except Exception:
|
| 155 |
+
pass
|
| 156 |
+
|
| 157 |
+
console.print("[bold cyan]Generating default models...[/bold cyan]")
|
| 158 |
+
|
| 159 |
+
labels_path = _save_labels()
|
| 160 |
+
console.print(f" [green]{labels_path}[/green]")
|
| 161 |
+
|
| 162 |
+
symp_path = _save_symptoms()
|
| 163 |
+
console.print(f" [green]{symp_path}[/green]")
|
| 164 |
+
|
| 165 |
+
cls_path = _export_classifier_onnx()
|
| 166 |
+
size = os.path.getsize(cls_path) / 1024
|
| 167 |
+
console.print(f" [green]{cls_path}[/green] ({size:.0f} KB)")
|
| 168 |
+
|
| 169 |
+
full_path = _export_full_onnx()
|
| 170 |
+
if full_path:
|
| 171 |
+
size = os.path.getsize(full_path) / 1024 / 1024
|
| 172 |
+
console.print(f" [green]{full_path}[/green] ({size:.0f} MB)")
|
| 173 |
+
else:
|
| 174 |
+
console.print(" [yellow]Full pipeline ONNX export skipped (no transformers or CUDA)[/yellow]")
|
| 175 |
+
console.print(" [yellow] Run 'python quantization.py --mode export-full' after training for this.[/yellow]")
|
| 176 |
+
|
| 177 |
+
console.print()
|
| 178 |
+
console.print("[green]Default models ready. The app will use these until you train a proper model.[/green]")
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
if __name__ == "__main__":
|
| 182 |
+
main()
|
training.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import csv
|
| 3 |
+
import os
|
| 4 |
+
import random
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
from PIL import Image
|
| 9 |
+
from torch.utils.data import Dataset, DataLoader, random_split
|
| 10 |
+
|
| 11 |
+
DATA_DIR = "./data"
|
| 12 |
+
CSV_PATH = os.path.join(DATA_DIR, "dataset.csv")
|
| 13 |
+
IMAGES_DIR = os.path.join(DATA_DIR, "images")
|
| 14 |
+
CHECKPOINT_DIR = "./checkpoints"
|
| 15 |
+
CHECKPOINT_PATH = os.path.join(CHECKPOINT_DIR, "fusion_model.pth")
|
| 16 |
+
CONFIDENCE_THRESHOLD = 0.75
|
| 17 |
+
|
| 18 |
+
CSV_COLUMNS = ["image_path", "source", "symptoms", "diagnosis", "labels"]
|
| 19 |
+
|
| 20 |
+
def load_label_list():
|
| 21 |
+
if not os.path.exists(CSV_PATH):
|
| 22 |
+
return []
|
| 23 |
+
labels = set()
|
| 24 |
+
with open(CSV_PATH, newline="", encoding="utf-8") as f:
|
| 25 |
+
for row in csv.DictReader(f):
|
| 26 |
+
d = row.get("diagnosis", "").strip().lower()
|
| 27 |
+
if d:
|
| 28 |
+
labels.add(d)
|
| 29 |
+
return sorted(labels)
|
| 30 |
+
|
| 31 |
+
def prepare_data():
|
| 32 |
+
from datasets import load_dataset
|
| 33 |
+
os.makedirs(IMAGES_DIR, exist_ok=True)
|
| 34 |
+
|
| 35 |
+
file_exists = os.path.exists(CSV_PATH)
|
| 36 |
+
if not file_exists:
|
| 37 |
+
with open(CSV_PATH, "w", newline="", encoding="utf-8") as f:
|
| 38 |
+
writer = csv.writer(f)
|
| 39 |
+
writer.writerow(CSV_COLUMNS)
|
| 40 |
+
|
| 41 |
+
rows_written = 0
|
| 42 |
+
sources_done = []
|
| 43 |
+
|
| 44 |
+
# ── IU-Xray (image + question + report) ──
|
| 45 |
+
print("Downloading IU-Xray from Hugging Face...")
|
| 46 |
+
iuxray = load_dataset("ayyuce/Indiana_University_Chest_X-ray_Collection", split="train")
|
| 47 |
+
written = 0
|
| 48 |
+
for i, example in enumerate(iuxray):
|
| 49 |
+
symptoms = (example.get("question") or "").strip()
|
| 50 |
+
diagnosis = (example.get("report") or "").strip()
|
| 51 |
+
image = example.get("image")
|
| 52 |
+
if not symptoms or not diagnosis or image is None:
|
| 53 |
+
continue
|
| 54 |
+
image_path = os.path.join(IMAGES_DIR, f"iu_xray_{i}.jpg")
|
| 55 |
+
image.convert("RGB").save(image_path)
|
| 56 |
+
with open(CSV_PATH, "a", newline="", encoding="utf-8") as f:
|
| 57 |
+
writer = csv.writer(f)
|
| 58 |
+
writer.writerow([image_path, "iu_xray", symptoms, diagnosis, ""])
|
| 59 |
+
written += 1
|
| 60 |
+
print(f" IU-Xray: {written} rows")
|
| 61 |
+
rows_written += written
|
| 62 |
+
sources_done.append(f"iu_xray ({written})")
|
| 63 |
+
|
| 64 |
+
# ── NIH Chest X-ray (image + disease labels) ──
|
| 65 |
+
print("Downloading NIH Chest X-ray from Hugging Face...")
|
| 66 |
+
nih = load_dataset("g-ronimo/NIH-Chest-X-ray-dataset_resized300px", split="train", streaming=True)
|
| 67 |
+
label_names = [
|
| 68 |
+
"No Finding", "Atelectasis", "Cardiomegaly", "Effusion", "Infiltration",
|
| 69 |
+
"Mass", "Nodule", "Pneumonia", "Pneumothorax", "Consolidation",
|
| 70 |
+
"Edema", "Emphysema", "Fibrosis", "Pleural_Thickening", "Hernia"
|
| 71 |
+
]
|
| 72 |
+
written = 0
|
| 73 |
+
for i, example in enumerate(nih):
|
| 74 |
+
if written >= 3000:
|
| 75 |
+
break
|
| 76 |
+
image = example.get("image")
|
| 77 |
+
label_indices = example.get("labels", [])
|
| 78 |
+
if image is None or not label_indices:
|
| 79 |
+
continue
|
| 80 |
+
label_str = "|".join(label_names[idx] for idx in label_indices)
|
| 81 |
+
primary_diagnosis = label_names[label_indices[0]]
|
| 82 |
+
image_path = os.path.join(IMAGES_DIR, f"nih_{i}.jpg")
|
| 83 |
+
image.convert("RGB").save(image_path)
|
| 84 |
+
with open(CSV_PATH, "a", newline="", encoding="utf-8") as f:
|
| 85 |
+
writer = csv.writer(f)
|
| 86 |
+
writer.writerow([image_path, "nih", "", primary_diagnosis, label_str])
|
| 87 |
+
written += 1
|
| 88 |
+
if written % 500 == 0:
|
| 89 |
+
print(f" NIH progress: {written}...")
|
| 90 |
+
print(f" NIH: {written} rows")
|
| 91 |
+
rows_written += written
|
| 92 |
+
sources_done.append(f"nih ({written})")
|
| 93 |
+
|
| 94 |
+
print(f"Done. Total: {rows_written} rows written to {CSV_PATH}")
|
| 95 |
+
print(f"Sources: {', '.join(sources_done)}")
|
| 96 |
+
|
| 97 |
+
def add_data(image_path, symptoms, diagnosis, labels=""):
|
| 98 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 99 |
+
file_exists = os.path.exists(CSV_PATH)
|
| 100 |
+
with open(CSV_PATH, "a", newline="", encoding="utf-8") as f:
|
| 101 |
+
writer = csv.writer(f)
|
| 102 |
+
if not file_exists:
|
| 103 |
+
writer.writerow(CSV_COLUMNS)
|
| 104 |
+
writer.writerow([image_path, "user", symptoms, diagnosis, labels])
|
| 105 |
+
print(f"Added 1 row to {CSV_PATH}: diagnosis='{diagnosis}'")
|
| 106 |
+
|
| 107 |
+
class FusionDataset(Dataset):
|
| 108 |
+
def __init__(self, csv_path, label_list):
|
| 109 |
+
self.rows = []
|
| 110 |
+
with open(csv_path, newline="", encoding="utf-8") as f:
|
| 111 |
+
for row in csv.DictReader(f):
|
| 112 |
+
d = row.get("diagnosis", "").strip().lower()
|
| 113 |
+
if d:
|
| 114 |
+
self.rows.append(row)
|
| 115 |
+
self.label_list = label_list
|
| 116 |
+
|
| 117 |
+
def __len__(self):
|
| 118 |
+
return len(self.rows)
|
| 119 |
+
|
| 120 |
+
def __getitem__(self, idx):
|
| 121 |
+
row = self.rows[idx]
|
| 122 |
+
image = Image.open(row["image_path"]).convert("RGB")
|
| 123 |
+
symptoms = row.get("symptoms", "").strip()
|
| 124 |
+
label_idx = self.label_list.index(row["diagnosis"].strip().lower())
|
| 125 |
+
return image, symptoms, label_idx
|
| 126 |
+
|
| 127 |
+
def collate_fn(batch):
|
| 128 |
+
images = [item[0] for item in batch]
|
| 129 |
+
symptoms = [item[1] for item in batch]
|
| 130 |
+
labels = torch.tensor([item[2] for item in batch], dtype=torch.long)
|
| 131 |
+
return images, symptoms, labels
|
| 132 |
+
|
| 133 |
+
class DiagnosisFusionModel(nn.Module):
|
| 134 |
+
def __init__(self, num_conditions):
|
| 135 |
+
super().__init__()
|
| 136 |
+
from transformers import CLIPModel, CLIPProcessor, AutoTokenizer, AutoModel
|
| 137 |
+
self.image_processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
|
| 138 |
+
self.image_encoder = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
|
| 139 |
+
self.symptom_tokenizer = AutoTokenizer.from_pretrained("emilyalsentzer/Bio_ClinicalBERT")
|
| 140 |
+
self.symptom_encoder = AutoModel.from_pretrained("emilyalsentzer/Bio_ClinicalBERT")
|
| 141 |
+
for param in self.image_encoder.parameters():
|
| 142 |
+
param.requires_grad = False
|
| 143 |
+
for param in self.symptom_encoder.parameters():
|
| 144 |
+
param.requires_grad = False
|
| 145 |
+
self.classifier = nn.Sequential(
|
| 146 |
+
nn.Linear(512 + 768, 256),
|
| 147 |
+
nn.ReLU(),
|
| 148 |
+
nn.Dropout(0.2),
|
| 149 |
+
nn.Linear(256, num_conditions),
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
def encode_images(self, images):
|
| 153 |
+
inputs = self.image_processor(images=images, return_tensors="pt")
|
| 154 |
+
with torch.no_grad():
|
| 155 |
+
return self.image_encoder.get_image_features(**inputs)
|
| 156 |
+
|
| 157 |
+
def encode_symptoms(self, symptom_texts):
|
| 158 |
+
inputs = self.symptom_tokenizer(
|
| 159 |
+
symptom_texts, return_tensors="pt", padding=True, truncation=True, max_length=64
|
| 160 |
+
)
|
| 161 |
+
with torch.no_grad():
|
| 162 |
+
outputs = self.symptom_encoder(**inputs)
|
| 163 |
+
return outputs.last_hidden_state.mean(dim=1)
|
| 164 |
+
|
| 165 |
+
def forward(self, images, symptom_texts):
|
| 166 |
+
image_vecs = self.encode_images(images)
|
| 167 |
+
symptom_vecs = self.encode_symptoms(symptom_texts)
|
| 168 |
+
combined = torch.cat([image_vecs, symptom_vecs], dim=-1)
|
| 169 |
+
return self.classifier(combined)
|
| 170 |
+
|
| 171 |
+
def train(epochs, batch_size, lr, val_split, use_amp, grad_accum):
|
| 172 |
+
from rich.console import Console
|
| 173 |
+
from rich.table import Table
|
| 174 |
+
from rich.progress import Progress, BarColumn, TextColumn, TimeElapsedColumn
|
| 175 |
+
_console = Console()
|
| 176 |
+
|
| 177 |
+
has_gpu = torch.cuda.is_available()
|
| 178 |
+
use_amp = use_amp and has_gpu
|
| 179 |
+
scaler = torch.cuda.amp.GradScaler() if use_amp else None
|
| 180 |
+
|
| 181 |
+
label_list = load_label_list()
|
| 182 |
+
if not label_list:
|
| 183 |
+
_console.print("[red]No data found. Run --mode prepare-data or --mode add-data first.[/red]")
|
| 184 |
+
return
|
| 185 |
+
|
| 186 |
+
dataset = FusionDataset(CSV_PATH, label_list)
|
| 187 |
+
val_size = max(int(val_split * len(dataset)), 1)
|
| 188 |
+
train_size = len(dataset) - val_size
|
| 189 |
+
train_subset, val_subset = random_split(dataset, [train_size, val_size])
|
| 190 |
+
|
| 191 |
+
train_loader = DataLoader(train_subset, batch_size=batch_size, shuffle=True, collate_fn=collate_fn)
|
| 192 |
+
val_loader = DataLoader(val_subset, batch_size=batch_size, shuffle=False, collate_fn=collate_fn)
|
| 193 |
+
|
| 194 |
+
_console.print(f"[bold cyan]Training Setup[/bold cyan]")
|
| 195 |
+
_console.print(f" Classes: {len(label_list)}")
|
| 196 |
+
_console.print(f" Train/Val: {len(train_subset)}/{len(val_subset)}")
|
| 197 |
+
_console.print(f" Batch size: {batch_size} Grad accum: {grad_accum}")
|
| 198 |
+
_console.print(f" Device: {'GPU' if has_gpu else 'CPU'} AMP: {'ON' if use_amp else 'OFF'}")
|
| 199 |
+
|
| 200 |
+
model = DiagnosisFusionModel(num_conditions=len(label_list))
|
| 201 |
+
if has_gpu:
|
| 202 |
+
model = model.cuda()
|
| 203 |
+
optimizer = torch.optim.AdamW(model.classifier.parameters(), lr=lr)
|
| 204 |
+
loss_fn = nn.CrossEntropyLoss()
|
| 205 |
+
|
| 206 |
+
for epoch in range(epochs):
|
| 207 |
+
_console.print(f"\n[bold yellow]Epoch {epoch + 1}/{epochs}[/bold yellow]")
|
| 208 |
+
_console.print("-" * 40)
|
| 209 |
+
|
| 210 |
+
# ── Train ──
|
| 211 |
+
model.train()
|
| 212 |
+
train_loss = 0.0
|
| 213 |
+
optimizer.zero_grad()
|
| 214 |
+
train_progress = Progress(
|
| 215 |
+
TextColumn("[cyan] Train[/cyan]"),
|
| 216 |
+
BarColumn(),
|
| 217 |
+
TextColumn("{task.completed}/{task.total}"),
|
| 218 |
+
TextColumn("[green]{task.fields[loss]:.4f}[/green]"),
|
| 219 |
+
TimeElapsedColumn(),
|
| 220 |
+
transient=True,
|
| 221 |
+
)
|
| 222 |
+
with train_progress:
|
| 223 |
+
task = train_progress.add_task("", total=len(train_loader), loss=0.0)
|
| 224 |
+
for i, (images, symptoms, labels) in enumerate(train_loader):
|
| 225 |
+
if has_gpu:
|
| 226 |
+
labels = labels.cuda()
|
| 227 |
+
with torch.amp.autocast("cuda", enabled=use_amp):
|
| 228 |
+
logits = model(images, symptoms)
|
| 229 |
+
loss = loss_fn(logits, labels)
|
| 230 |
+
loss = loss / grad_accum
|
| 231 |
+
if use_amp:
|
| 232 |
+
scaler.scale(loss).backward()
|
| 233 |
+
else:
|
| 234 |
+
loss.backward()
|
| 235 |
+
|
| 236 |
+
if (i + 1) % grad_accum == 0 or (i + 1) == len(train_loader):
|
| 237 |
+
if use_amp:
|
| 238 |
+
scaler.step(optimizer)
|
| 239 |
+
scaler.update()
|
| 240 |
+
else:
|
| 241 |
+
optimizer.step()
|
| 242 |
+
optimizer.zero_grad()
|
| 243 |
+
|
| 244 |
+
train_loss += loss.item() * grad_accum
|
| 245 |
+
train_progress.update(task, advance=1, loss=loss.item() * grad_accum)
|
| 246 |
+
|
| 247 |
+
avg_train_loss = train_loss / len(train_loader)
|
| 248 |
+
|
| 249 |
+
# ── Validation ��─
|
| 250 |
+
model.eval()
|
| 251 |
+
val_loss = 0.0
|
| 252 |
+
with torch.no_grad():
|
| 253 |
+
for images, symptoms, labels in val_loader:
|
| 254 |
+
if has_gpu:
|
| 255 |
+
labels = labels.cuda()
|
| 256 |
+
logits = model(images, symptoms)
|
| 257 |
+
loss = loss_fn(logits, labels)
|
| 258 |
+
val_loss += loss.item()
|
| 259 |
+
|
| 260 |
+
avg_val_loss = val_loss / len(val_loader)
|
| 261 |
+
|
| 262 |
+
table = Table(show_header=False, box=None)
|
| 263 |
+
table.add_column("Metric", style="cyan")
|
| 264 |
+
table.add_column("Value", style="green")
|
| 265 |
+
table.add_row("Train loss", f"{avg_train_loss:.4f}")
|
| 266 |
+
table.add_row("Val loss", f"{avg_val_loss:.4f}")
|
| 267 |
+
_console.print(table)
|
| 268 |
+
|
| 269 |
+
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
|
| 270 |
+
torch.save({"model_state": model.classifier.state_dict(), "label_list": label_list}, CHECKPOINT_PATH)
|
| 271 |
+
_console.print(f"[green]Saved checkpoint to {CHECKPOINT_PATH}[/green]")
|
| 272 |
+
|
| 273 |
+
def test(batch_size):
|
| 274 |
+
if not os.path.exists(CHECKPOINT_PATH):
|
| 275 |
+
print("No checkpoint found. Run --mode train first.")
|
| 276 |
+
return
|
| 277 |
+
checkpoint = torch.load(CHECKPOINT_PATH, weights_only=False)
|
| 278 |
+
label_list = checkpoint["label_list"]
|
| 279 |
+
model = DiagnosisFusionModel(num_conditions=len(label_list))
|
| 280 |
+
model.classifier.load_state_dict(checkpoint["model_state"])
|
| 281 |
+
model.eval()
|
| 282 |
+
dataset = FusionDataset(CSV_PATH, label_list)
|
| 283 |
+
test_size = max(int(0.2 * len(dataset)), 1)
|
| 284 |
+
_, test_subset = random_split(dataset, [len(dataset) - test_size, test_size])
|
| 285 |
+
loader = DataLoader(test_subset, batch_size=batch_size, shuffle=False, collate_fn=collate_fn)
|
| 286 |
+
correct = 0
|
| 287 |
+
inconclusive = 0
|
| 288 |
+
total = 0
|
| 289 |
+
with torch.no_grad():
|
| 290 |
+
for images, symptoms, labels in loader:
|
| 291 |
+
logits = model(images, symptoms)
|
| 292 |
+
probs = torch.softmax(logits, dim=-1)
|
| 293 |
+
confidence, predicted = torch.max(probs, dim=-1)
|
| 294 |
+
for i in range(len(labels)):
|
| 295 |
+
total += 1
|
| 296 |
+
if confidence[i].item() < CONFIDENCE_THRESHOLD:
|
| 297 |
+
inconclusive += 1
|
| 298 |
+
elif predicted[i].item() == labels[i].item():
|
| 299 |
+
correct += 1
|
| 300 |
+
print(f"Tested on {total} held-out examples")
|
| 301 |
+
print(f"Correct (above confidence threshold): {correct} ({100 * correct / total:.1f}%)")
|
| 302 |
+
print(f"Flagged as inconclusive / needs follow-up: {inconclusive} ({100 * inconclusive / total:.1f}%)")
|
| 303 |
+
|
| 304 |
+
def info():
|
| 305 |
+
if not os.path.exists(CSV_PATH):
|
| 306 |
+
print("No dataset.csv found. Run --mode prepare-data first.")
|
| 307 |
+
return
|
| 308 |
+
sources = {}
|
| 309 |
+
total = 0
|
| 310 |
+
with open(CSV_PATH, newline="", encoding="utf-8") as f:
|
| 311 |
+
for row in csv.DictReader(f):
|
| 312 |
+
src = row.get("source", "unknown")
|
| 313 |
+
sources[src] = sources.get(src, 0) + 1
|
| 314 |
+
total += 1
|
| 315 |
+
print(f"Dataset: {CSV_PATH}")
|
| 316 |
+
print(f"Total rows: {total}")
|
| 317 |
+
for src, count in sorted(sources.items()):
|
| 318 |
+
print(f" {src}: {count}")
|
| 319 |
+
print(f"Images dir: {IMAGES_DIR}")
|
| 320 |
+
img_count = len([x for x in os.listdir(IMAGES_DIR) if os.path.isfile(os.path.join(IMAGES_DIR, x))]) if os.path.exists(IMAGES_DIR) else 0
|
| 321 |
+
print(f"Images: {img_count}")
|
| 322 |
+
|
| 323 |
+
if __name__ == "__main__":
|
| 324 |
+
parser = argparse.ArgumentParser(description="Train/test the medical image+symptom fusion model")
|
| 325 |
+
parser.add_argument("--mode", required=True, choices=["prepare-data", "add-data", "train", "test", "info"])
|
| 326 |
+
parser.add_argument("--image", help="Path to an image file (for --mode add-data)")
|
| 327 |
+
parser.add_argument("--symptoms", help="Symptom description text (for --mode add-data)")
|
| 328 |
+
parser.add_argument("--diagnosis", help="Diagnosis label (for --mode add-data)")
|
| 329 |
+
parser.add_argument("--epochs", type=int, default=5)
|
| 330 |
+
parser.add_argument("--batch_size", type=int, default=8)
|
| 331 |
+
parser.add_argument("--lr", type=float, default=1e-3)
|
| 332 |
+
parser.add_argument("--val_split", type=float, default=0.15, help="Fraction of data for validation")
|
| 333 |
+
parser.add_argument("--use_amp", action="store_true", help="Enable mixed precision (GPU only)")
|
| 334 |
+
parser.add_argument("--grad_accum", type=int, default=1, help="Gradient accumulation steps")
|
| 335 |
+
args = parser.parse_args()
|
| 336 |
+
|
| 337 |
+
if args.mode == "prepare-data":
|
| 338 |
+
prepare_data()
|
| 339 |
+
elif args.mode == "add-data":
|
| 340 |
+
if not (args.image and args.symptoms and args.diagnosis):
|
| 341 |
+
print("--mode add-data requires --image, --symptoms, and --diagnosis")
|
| 342 |
+
else:
|
| 343 |
+
add_data(args.image, args.symptoms, args.diagnosis)
|
| 344 |
+
elif args.mode == "train":
|
| 345 |
+
train(args.epochs, args.batch_size, args.lr, args.val_split, args.use_amp, args.grad_accum)
|
| 346 |
+
elif args.mode == "test":
|
| 347 |
+
test(args.batch_size)
|
| 348 |
+
elif args.mode == "info":
|
| 349 |
+
info()
|
update.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Update script — pulls the newest models, UI files, and dependencies.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python update.py # interactive menu
|
| 6 |
+
python update.py --all # update everything
|
| 7 |
+
python update.py --models # download latest ONNX models
|
| 8 |
+
python update.py --code # git pull latest source
|
| 9 |
+
python update.py --deps # upgrade pip packages
|
| 10 |
+
|
| 11 |
+
The default model source is a Hugging Face repo.
|
| 12 |
+
Configure with: python update.py --set-url <HF_REPO_ID>
|
| 13 |
+
"""
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import subprocess
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
CONFIG_FILE = "update_config.json"
|
| 22 |
+
DEFAULT_HF_REPO = "your-org/medicalai-models"
|
| 23 |
+
DEFAULT_DIR = Path("models") / "default"
|
| 24 |
+
ONNX_FULL_DIR = Path("checkpoints") / "onnx_full"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_config():
|
| 28 |
+
if os.path.exists(CONFIG_FILE):
|
| 29 |
+
with open(CONFIG_FILE) as f:
|
| 30 |
+
return json.load(f)
|
| 31 |
+
return {"hf_repo": DEFAULT_HF_REPO, "auto_update": True}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def save_config(cfg):
|
| 35 |
+
with open(CONFIG_FILE, "w") as f:
|
| 36 |
+
json.dump(cfg, f, indent=2)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _ensure_dir(path):
|
| 40 |
+
os.makedirs(path, exist_ok=True)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _file_size(path):
|
| 44 |
+
return os.path.getsize(path) / 1024 / 1024
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def update_models_from_hf():
|
| 48 |
+
"""Download latest ONNX models from Hugging Face."""
|
| 49 |
+
cfg = load_config()
|
| 50 |
+
repo = cfg["hf_repo"]
|
| 51 |
+
|
| 52 |
+
from rich.console import Console
|
| 53 |
+
console = Console()
|
| 54 |
+
|
| 55 |
+
if repo == DEFAULT_HF_REPO and "your-org" in repo:
|
| 56 |
+
console.print("[yellow]HF_REPO not set. Skipping model download.[/yellow]")
|
| 57 |
+
console.print("[yellow]Set your model repo: python update.py --set-url your-org/your-repo[/yellow]")
|
| 58 |
+
console.print("[yellow]Or train locally: python training.py --mode prepare-data && python training.py --mode train[/yellow]")
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
import requests
|
| 63 |
+
except ImportError:
|
| 64 |
+
console.print("[red]'requests' required. Install: pip install requests[/red]")
|
| 65 |
+
return
|
| 66 |
+
|
| 67 |
+
_ensure_dir(ONNX_FULL_DIR)
|
| 68 |
+
_ensure_dir(DEFAULT_DIR)
|
| 69 |
+
|
| 70 |
+
files_to_download = [
|
| 71 |
+
("fusion_full.onnx", ONNX_FULL_DIR / "fusion_full.onnx"),
|
| 72 |
+
("labels.json", ONNX_FULL_DIR / "labels.json"),
|
| 73 |
+
("fusion_classifier.onnx", DEFAULT_DIR / "fusion_classifier.onnx"),
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
| 77 |
+
|
| 78 |
+
for fname, dest in files_to_download:
|
| 79 |
+
url = f"{base_url}/{fname}"
|
| 80 |
+
console.print(f"[cyan]Downloading {fname}...[/cyan]")
|
| 81 |
+
try:
|
| 82 |
+
resp = requests.get(url, stream=True, timeout=30)
|
| 83 |
+
resp.raise_for_status()
|
| 84 |
+
with open(dest, "wb") as f:
|
| 85 |
+
for chunk in resp.iter_content(8192):
|
| 86 |
+
f.write(chunk)
|
| 87 |
+
console.print(f" [green]Saved {dest} ({_file_size(dest):.1f} MB)[/green]")
|
| 88 |
+
except Exception as e:
|
| 89 |
+
console.print(f" [red]Failed: {e}[/red]")
|
| 90 |
+
|
| 91 |
+
console.print("[green]Model update complete.[/green]")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def update_code():
|
| 95 |
+
"""Pull latest source code from git."""
|
| 96 |
+
from rich.console import Console
|
| 97 |
+
console = Console()
|
| 98 |
+
|
| 99 |
+
if not os.path.exists(".git"):
|
| 100 |
+
console.print("[yellow]Not a git repository. Skipping code update.[/yellow]")
|
| 101 |
+
return
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
result = subprocess.run(
|
| 105 |
+
["git", "pull", "--ff-only"],
|
| 106 |
+
capture_output=True, text=True, timeout=60,
|
| 107 |
+
)
|
| 108 |
+
if result.returncode == 0:
|
| 109 |
+
console.print(f"[green]{result.stdout}[/green]")
|
| 110 |
+
else:
|
| 111 |
+
console.print(f"[yellow]{result.stderr}[/yellow]")
|
| 112 |
+
except Exception as e:
|
| 113 |
+
console.print(f"[red]Git pull failed: {e}[/red]")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def update_deps():
|
| 117 |
+
"""Upgrade all pip packages to latest compatible versions."""
|
| 118 |
+
from rich.console import Console
|
| 119 |
+
console = Console()
|
| 120 |
+
|
| 121 |
+
req = "requirements.txt"
|
| 122 |
+
if not os.path.exists(req):
|
| 123 |
+
console.print("[yellow]No requirements.txt found.[/yellow]")
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
console.print("[cyan]Upgrading dependencies...[/cyan]")
|
| 127 |
+
try:
|
| 128 |
+
subprocess.check_call(
|
| 129 |
+
[sys.executable, "-m", "pip", "install", "--upgrade", "-r", req],
|
| 130 |
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
| 131 |
+
)
|
| 132 |
+
console.print("[green]Dependencies upgraded.[/green]")
|
| 133 |
+
except Exception as e:
|
| 134 |
+
console.print(f"[red]Upgrade failed: {e}[/red]")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def update_all():
|
| 138 |
+
from rich.console import Console
|
| 139 |
+
console = Console()
|
| 140 |
+
|
| 141 |
+
console.print("[bold cyan]Full Update[/bold cyan]")
|
| 142 |
+
console.print()
|
| 143 |
+
|
| 144 |
+
console.print("[cyan]Step 1: Updating code...[/cyan]")
|
| 145 |
+
update_code()
|
| 146 |
+
|
| 147 |
+
console.print("[cyan]Step 2: Upgrading dependencies...[/cyan]")
|
| 148 |
+
update_deps()
|
| 149 |
+
|
| 150 |
+
console.print("[cyan]Step 3: Downloading latest models...[/cyan]")
|
| 151 |
+
update_models_from_hf()
|
| 152 |
+
|
| 153 |
+
console.print()
|
| 154 |
+
console.print("[green]Update complete![/green]")
|
| 155 |
+
console.print(" Run [bold]python quantization.py --mode status[/bold] to verify.")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def set_repo_url(url):
|
| 159 |
+
cfg = load_config()
|
| 160 |
+
cfg["hf_repo"] = url
|
| 161 |
+
save_config(cfg)
|
| 162 |
+
print(f"Hugging Face repo set to: {url}")
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def show_status():
|
| 166 |
+
cfg = load_config()
|
| 167 |
+
print(f"Update config: {CONFIG_FILE}")
|
| 168 |
+
print(f" HF repo: {cfg['hf_repo']}")
|
| 169 |
+
print(f" Auto update: {cfg['auto_update']}")
|
| 170 |
+
print()
|
| 171 |
+
print("Default models:")
|
| 172 |
+
for f in ["fusion_classifier.onnx", "labels.json"]:
|
| 173 |
+
p = DEFAULT_DIR / f
|
| 174 |
+
exists = os.path.exists(p)
|
| 175 |
+
size = f"({_file_size(p):.1f} MB)" if exists else ""
|
| 176 |
+
print(f" {f}: {'yes' if exists else 'no'} {size}")
|
| 177 |
+
print()
|
| 178 |
+
print("Full ONNX pipeline:")
|
| 179 |
+
for f in ["fusion_full.onnx", "labels.json"]:
|
| 180 |
+
p = ONNX_FULL_DIR / f
|
| 181 |
+
exists = os.path.exists(p)
|
| 182 |
+
size = f"({_file_size(p):.1f} MB)" if exists else ""
|
| 183 |
+
print(f" {f}: {'yes' if exists else 'no'} {size}")
|
| 184 |
+
print()
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def main():
|
| 188 |
+
parser = argparse.ArgumentParser(description="Update MedicalAI models, code, and deps")
|
| 189 |
+
parser.add_argument("--models", action="store_true", help="Download latest ONNX models")
|
| 190 |
+
parser.add_argument("--code", action="store_true", help="Git pull latest source")
|
| 191 |
+
parser.add_argument("--deps", action="store_true", help="Upgrade pip packages")
|
| 192 |
+
parser.add_argument("--all", action="store_true", help="Update everything")
|
| 193 |
+
parser.add_argument("--set-url", metavar="HF_REPO", help="Set Hugging Face model repo")
|
| 194 |
+
parser.add_argument("--status", action="store_true", help="Show update status")
|
| 195 |
+
args = parser.parse_args()
|
| 196 |
+
|
| 197 |
+
if args.set_url:
|
| 198 |
+
set_repo_url(args.set_url)
|
| 199 |
+
return
|
| 200 |
+
|
| 201 |
+
if args.status:
|
| 202 |
+
show_status()
|
| 203 |
+
return
|
| 204 |
+
|
| 205 |
+
if args.all:
|
| 206 |
+
update_all()
|
| 207 |
+
return
|
| 208 |
+
|
| 209 |
+
if args.models:
|
| 210 |
+
update_models_from_hf()
|
| 211 |
+
return
|
| 212 |
+
|
| 213 |
+
if args.code:
|
| 214 |
+
update_code()
|
| 215 |
+
return
|
| 216 |
+
|
| 217 |
+
if args.deps:
|
| 218 |
+
update_deps()
|
| 219 |
+
return
|
| 220 |
+
|
| 221 |
+
# Interactive mode
|
| 222 |
+
from rich.console import Console
|
| 223 |
+
import questionary
|
| 224 |
+
|
| 225 |
+
console = Console()
|
| 226 |
+
console.print("[bold cyan]MedicalAI - Update Manager[/bold cyan]")
|
| 227 |
+
console.print()
|
| 228 |
+
|
| 229 |
+
choice = questionary.select(
|
| 230 |
+
"What would you like to update?",
|
| 231 |
+
choices=[
|
| 232 |
+
"Everything (code + deps + models)",
|
| 233 |
+
"Models only (download latest ONNX)",
|
| 234 |
+
"Code only (git pull)",
|
| 235 |
+
"Dependencies only (pip upgrade)",
|
| 236 |
+
"Show update status",
|
| 237 |
+
"Set Hugging Face model repo",
|
| 238 |
+
"Cancel",
|
| 239 |
+
],
|
| 240 |
+
).ask()
|
| 241 |
+
|
| 242 |
+
if choice == "Everything (code + deps + models)":
|
| 243 |
+
update_all()
|
| 244 |
+
elif choice == "Models only (download latest ONNX)":
|
| 245 |
+
update_models_from_hf()
|
| 246 |
+
elif choice == "Code only (git pull)":
|
| 247 |
+
update_code()
|
| 248 |
+
elif choice == "Dependencies only (pip upgrade)":
|
| 249 |
+
update_deps()
|
| 250 |
+
elif choice == "Show update status":
|
| 251 |
+
show_status()
|
| 252 |
+
elif "Set Hugging Face" in choice:
|
| 253 |
+
repo = questionary.text("Enter Hugging Face repo (user/repo):").ask()
|
| 254 |
+
if repo:
|
| 255 |
+
set_repo_url(repo)
|
| 256 |
+
else:
|
| 257 |
+
console.print("[yellow]Cancelled.[/yellow]")
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
main()
|
web_demo/index.html
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>MedicalAI Web Demo</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 9 |
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f4f8; color: #1a202c; padding: 2rem; }
|
| 10 |
+
.container { max-width: 800px; margin: 0 auto; }
|
| 11 |
+
h1 { font-size: 1.75rem; margin-bottom: 0.25rem; }
|
| 12 |
+
.subtitle { color: #4a5568; margin-bottom: 2rem; }
|
| 13 |
+
.card { background: #fff; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
| 14 |
+
.card h2 { font-size: 1.1rem; margin-bottom: 1rem; color: #2b6cb0; }
|
| 15 |
+
label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 0.375rem; color: #2d3748; }
|
| 16 |
+
input[type="file"], textarea, input[type="text"] { width: 100%; padding: 0.625rem; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 0.9rem; }
|
| 17 |
+
textarea { resize: vertical; min-height: 60px; font-family: inherit; }
|
| 18 |
+
button { background: #2b6cb0; color: #fff; border: none; padding: 0.625rem 1.5rem; border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; margin-top: 0.75rem; }
|
| 19 |
+
button:hover { background: #2c5282; }
|
| 20 |
+
button:disabled { opacity: 0.6; cursor: not-allowed; }
|
| 21 |
+
.result { margin-top: 1rem; padding: 1rem; background: #f7fafc; border-radius: 8px; border: 1px solid #e2e8f0; min-height: 40px; white-space: pre-wrap; }
|
| 22 |
+
.result .label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #718096; margin-bottom: 0.25rem; }
|
| 23 |
+
.result .value { font-size: 1rem; }
|
| 24 |
+
.badge { display: inline-block; font-size: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 4px; font-weight: 600; }
|
| 25 |
+
.badge.online { background: #c6f6d5; color: #22543d; }
|
| 26 |
+
.badge.offline { background: #fed7d7; color: #822727; }
|
| 27 |
+
.row { display: flex; gap: 1rem; align-items: flex-end; }
|
| 28 |
+
.row > * { flex: 1; }
|
| 29 |
+
.status-bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
| 30 |
+
.preview img { max-width: 100%; max-height: 240px; border-radius: 8px; margin-top: 0.5rem; }
|
| 31 |
+
.code { background: #1a202c; color: #e2e8f0; padding: 1rem; border-radius: 8px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.8rem; overflow-x: auto; }
|
| 32 |
+
.loading { display: inline-block; width: 16px; height: 16px; border: 2px solid #e2e8f0; border-top-color: #2b6cb0; border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 0.5rem; vertical-align: middle; }
|
| 33 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 34 |
+
</style>
|
| 35 |
+
</head>
|
| 36 |
+
<body>
|
| 37 |
+
<div class="container">
|
| 38 |
+
<h1>MedicalAI Web Demo</h1>
|
| 39 |
+
<p class="subtitle">Connect your website to the MedicalAI API server</p>
|
| 40 |
+
|
| 41 |
+
<div class="status-bar" style="margin-bottom:1.5rem;">
|
| 42 |
+
<span id="healthBadge" class="badge offline">Checking API...</span>
|
| 43 |
+
<span id="modelInfo" style="font-size:0.85rem;color:#718096;"></span>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div class="card">
|
| 47 |
+
<h2>Radiology Caption</h2>
|
| 48 |
+
<p style="font-size:0.85rem;color:#718096;margin-bottom:1rem;">Upload a chest X-ray and get an AI-generated radiology report.</p>
|
| 49 |
+
<label for="visionFile">X-ray Image</label>
|
| 50 |
+
<input type="file" id="visionFile" accept="image/*" />
|
| 51 |
+
<div id="visionPreview" class="preview"></div>
|
| 52 |
+
<button id="visionBtn" disabled>Analyze</button>
|
| 53 |
+
<div id="visionResult" class="result"><span style="color:#a0aec0;">Upload an image and click Analyze.</span></div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<div class="card">
|
| 57 |
+
<h2>Symptom Check</h2>
|
| 58 |
+
<p style="font-size:0.85rem;color:#718096;margin-bottom:1rem;">Upload an X-ray with patient symptoms for diagnosis.</p>
|
| 59 |
+
<div class="row">
|
| 60 |
+
<div>
|
| 61 |
+
<label for="symptomFile">X-ray Image</label>
|
| 62 |
+
<input type="file" id="symptomFile" accept="image/*" />
|
| 63 |
+
<div id="symptomPreview" class="preview"></div>
|
| 64 |
+
</div>
|
| 65 |
+
<div>
|
| 66 |
+
<label for="symptoms">Symptoms / Clinical Indication</label>
|
| 67 |
+
<textarea id="symptoms" placeholder="e.g., shortness of breath, cough, fever..."></textarea>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
<button id="symptomBtn" disabled>Diagnose</button>
|
| 71 |
+
<div id="symptomResult" class="result"><span style="color:#a0aec0;">Upload an image and enter symptoms.</span></div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div class="card">
|
| 75 |
+
<h2>How to connect your website</h2>
|
| 76 |
+
<p style="font-size:0.85rem;color:#718096;margin-bottom:1rem;">Use <code>fetch()</code> from JavaScript to call the API server running on your machine.</p>
|
| 77 |
+
<div class="code">// Example: Radiology Caption
|
| 78 |
+
const form = new FormData();
|
| 79 |
+
form.append('file', imageFile);
|
| 80 |
+
|
| 81 |
+
const res = await fetch('http://YOUR_SERVER:8000/api/vision', {
|
| 82 |
+
method: 'POST',
|
| 83 |
+
body: form,
|
| 84 |
+
});
|
| 85 |
+
const data = await res.json();
|
| 86 |
+
console.log(data.caption);
|
| 87 |
+
|
| 88 |
+
// Example: Symptom Check
|
| 89 |
+
const form2 = new FormData();
|
| 90 |
+
form2.append('file', imageFile);
|
| 91 |
+
form2.append('symptoms', 'cough, fever');
|
| 92 |
+
|
| 93 |
+
const res2 = await fetch('http://YOUR_SERVER:8000/api/symptom-check', {
|
| 94 |
+
method: 'POST',
|
| 95 |
+
body: form2,
|
| 96 |
+
});
|
| 97 |
+
const data2 = await res2.json();
|
| 98 |
+
console.log(data2.diagnosis, data2.confidence);</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<script>
|
| 103 |
+
const API_BASE = 'http://127.0.0.1:8000';
|
| 104 |
+
|
| 105 |
+
async function checkHealth() {
|
| 106 |
+
const badge = document.getElementById('healthBadge');
|
| 107 |
+
const info = document.getElementById('modelInfo');
|
| 108 |
+
try {
|
| 109 |
+
const res = await fetch(`${API_BASE}/health`);
|
| 110 |
+
if (!res.ok) throw new Error('Not OK');
|
| 111 |
+
const data = await res.json();
|
| 112 |
+
badge.className = 'badge online';
|
| 113 |
+
badge.textContent = 'API Online';
|
| 114 |
+
info.textContent = `Device: ${data.device} · Model: ${data.model_source}`;
|
| 115 |
+
document.getElementById('visionBtn').disabled = false;
|
| 116 |
+
document.getElementById('symptomBtn').disabled = false;
|
| 117 |
+
} catch {
|
| 118 |
+
badge.className = 'badge offline';
|
| 119 |
+
badge.textContent = 'API Offline';
|
| 120 |
+
info.textContent = 'Start the API server: python quantization.py --mode serve-api';
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
checkHealth();
|
| 124 |
+
|
| 125 |
+
function previewImage(input, previewId) {
|
| 126 |
+
const preview = document.getElementById(previewId);
|
| 127 |
+
preview.innerHTML = '';
|
| 128 |
+
if (input.files && input.files[0]) {
|
| 129 |
+
const img = document.createElement('img');
|
| 130 |
+
img.src = URL.createObjectURL(input.files[0]);
|
| 131 |
+
preview.appendChild(img);
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
document.getElementById('visionFile').addEventListener('change', function() {
|
| 135 |
+
previewImage(this, 'visionPreview');
|
| 136 |
+
});
|
| 137 |
+
document.getElementById('symptomFile').addEventListener('change', function() {
|
| 138 |
+
previewImage(this, 'symptomPreview');
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
async function callEndpoint(endpoint, fileInput, extraFields, resultId) {
|
| 142 |
+
const btn = resultId === 'visionResult' ? 'visionBtn' : 'symptomBtn';
|
| 143 |
+
const btnEl = document.getElementById(btn);
|
| 144 |
+
const resultEl = document.getElementById(resultId);
|
| 145 |
+
|
| 146 |
+
if (!fileInput.files || !fileInput.files[0]) {
|
| 147 |
+
resultEl.innerHTML = '<span style="color:#e53e3e;">Please select an image.</span>';
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
btnEl.disabled = true;
|
| 152 |
+
btnEl.innerHTML = '<span class="loading"></span> Analyzing...';
|
| 153 |
+
resultEl.innerHTML = '<span class="loading"></span> Running inference...';
|
| 154 |
+
|
| 155 |
+
const form = new FormData();
|
| 156 |
+
form.append('file', fileInput.files[0]);
|
| 157 |
+
for (const [key, val] of Object.entries(extraFields)) {
|
| 158 |
+
form.append(key, val);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
try {
|
| 162 |
+
const res = await fetch(`${API_BASE}${endpoint}`, { method: 'POST', body: form });
|
| 163 |
+
const data = await res.json();
|
| 164 |
+
if (!res.ok) throw new Error(data.detail || 'Request failed');
|
| 165 |
+
|
| 166 |
+
if (endpoint === '/api/vision') {
|
| 167 |
+
resultEl.innerHTML = `<div><div class="label">Radiology Caption</div><div class="value">${data.caption}</div><div style="font-size:0.75rem;color:#a0aec0;margin-top:0.5rem;">${data.inference_time_ms} ms</div></div>`;
|
| 168 |
+
} else {
|
| 169 |
+
resultEl.innerHTML = `<div><div class="label">Diagnosis</div><div class="value">${data.diagnosis}</div><div style="margin-top:0.5rem;"><span class="label">Confidence</span> <span class="value">${(data.confidence * 100).toFixed(1)}%</span></div><div style="font-size:0.75rem;color:#a0aec0;margin-top:0.5rem;">${data.inference_time_ms} ms</div></div>`;
|
| 170 |
+
}
|
| 171 |
+
} catch (err) {
|
| 172 |
+
resultEl.innerHTML = `<span style="color:#e53e3e;">Error: ${err.message}</span>`;
|
| 173 |
+
} finally {
|
| 174 |
+
btnEl.disabled = false;
|
| 175 |
+
btnEl.textContent = endpoint === '/api/vision' ? 'Analyze' : 'Diagnose';
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
document.getElementById('visionBtn').addEventListener('click', () => {
|
| 180 |
+
callEndpoint('/api/vision', document.getElementById('visionFile'), {}, 'visionResult');
|
| 181 |
+
});
|
| 182 |
+
document.getElementById('symptomBtn').addEventListener('click', () => {
|
| 183 |
+
const symptoms = document.getElementById('symptoms').value || 'No symptoms provided';
|
| 184 |
+
callEndpoint('/api/symptom-check', document.getElementById('symptomFile'), { symptoms }, 'symptomResult');
|
| 185 |
+
});
|
| 186 |
+
</script>
|
| 187 |
+
</body>
|
| 188 |
+
</html>
|
web_ui.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Browser-based UI for MedicalAI - Light Weight.
|
| 3 |
+
Auto-installs gradio if missing.
|
| 4 |
+
Auto-generates default models if none are trained.
|
| 5 |
+
"""
|
| 6 |
+
import importlib
|
| 7 |
+
import os
|
| 8 |
+
import subprocess
|
| 9 |
+
import sys
|
| 10 |
+
import tempfile
|
| 11 |
+
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
from optimize import (
|
| 15 |
+
clear_memory,
|
| 16 |
+
get_available_models,
|
| 17 |
+
get_device,
|
| 18 |
+
infer_blip,
|
| 19 |
+
infer_fusion,
|
| 20 |
+
infer_fusion_onnx,
|
| 21 |
+
set_cpu_threads,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
CHECKPOINT_PATH = "./checkpoints/fusion_model.pth"
|
| 25 |
+
ONNX_FULL_DIR = "./checkpoints/onnx_full"
|
| 26 |
+
DEFAULT_MODEL_DIR = "./models/default"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _ensure_gradio():
|
| 30 |
+
try:
|
| 31 |
+
return importlib.import_module("gradio")
|
| 32 |
+
except ImportError:
|
| 33 |
+
from rich.console import Console
|
| 34 |
+
console = Console()
|
| 35 |
+
console.print("[yellow]'gradio' is required for the web UI.[/yellow]")
|
| 36 |
+
import questionary
|
| 37 |
+
install = questionary.confirm("Install gradio now?", default=True).ask()
|
| 38 |
+
if not install:
|
| 39 |
+
console.print("[red]gradio is required. Exiting.[/red]")
|
| 40 |
+
sys.exit(1)
|
| 41 |
+
console.print("[cyan]Installing gradio...[/cyan]")
|
| 42 |
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "gradio"])
|
| 43 |
+
return importlib.import_module("gradio")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _ensure_default_models():
|
| 47 |
+
"""Generate default ONNX models if none exist at all."""
|
| 48 |
+
if os.path.exists(os.path.join(DEFAULT_MODEL_DIR, "fusion_classifier.onnx")):
|
| 49 |
+
return
|
| 50 |
+
if os.path.exists(CHECKPOINT_PATH):
|
| 51 |
+
return
|
| 52 |
+
print("No models found. Generating default models...")
|
| 53 |
+
subprocess.check_call(
|
| 54 |
+
[sys.executable, "setup_default.py"],
|
| 55 |
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
| 56 |
+
)
|
| 57 |
+
print("Default models ready.")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def analyze_vision(image):
|
| 61 |
+
if image is None:
|
| 62 |
+
return "Please upload an X-ray image."
|
| 63 |
+
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
| 64 |
+
path = f.name
|
| 65 |
+
Image.fromarray(image).save(path)
|
| 66 |
+
try:
|
| 67 |
+
caption = infer_blip(path, use_onnx=False)
|
| 68 |
+
return caption
|
| 69 |
+
except Exception as e:
|
| 70 |
+
return f"Error: {e}"
|
| 71 |
+
finally:
|
| 72 |
+
os.unlink(path)
|
| 73 |
+
clear_memory()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def analyze_symptom(image, symptoms, use_onnx):
|
| 77 |
+
if image is None:
|
| 78 |
+
return "Please upload an X-ray image.", ""
|
| 79 |
+
if not symptoms:
|
| 80 |
+
symptoms = "No symptoms provided"
|
| 81 |
+
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
| 82 |
+
path = f.name
|
| 83 |
+
Image.fromarray(image).save(path)
|
| 84 |
+
try:
|
| 85 |
+
if use_onnx:
|
| 86 |
+
diagnosis, confidence = infer_fusion_onnx(path, symptoms)
|
| 87 |
+
else:
|
| 88 |
+
diagnosis, confidence = infer_fusion(path, symptoms)
|
| 89 |
+
if diagnosis is None:
|
| 90 |
+
return confidence, "No result"
|
| 91 |
+
return diagnosis, f"{confidence:.1%}"
|
| 92 |
+
except Exception as e:
|
| 93 |
+
return f"Error: {e}", ""
|
| 94 |
+
finally:
|
| 95 |
+
os.unlink(path)
|
| 96 |
+
clear_memory()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def main():
|
| 100 |
+
set_cpu_threads()
|
| 101 |
+
_ensure_default_models()
|
| 102 |
+
gr = _ensure_gradio()
|
| 103 |
+
|
| 104 |
+
models = get_available_models()
|
| 105 |
+
has_trained = models["trained_pytorch"]
|
| 106 |
+
has_default = models["default_classifier"]
|
| 107 |
+
has_onnx_full = models["onnx_full_pipeline"]
|
| 108 |
+
|
| 109 |
+
model_source = "ONNX (full pipeline)" if has_onnx_full else \
|
| 110 |
+
"Trained PyTorch" if has_trained else \
|
| 111 |
+
"Default (random weights)" if has_default else \
|
| 112 |
+
"NOT AVAILABLE"
|
| 113 |
+
|
| 114 |
+
print(f"Device: {get_device().upper()}")
|
| 115 |
+
print(f"Model: {model_source}")
|
| 116 |
+
print()
|
| 117 |
+
print("Launching web UI... open the URL below in your browser.")
|
| 118 |
+
|
| 119 |
+
with gr.Blocks(title="MedicalAI - Light Weight", theme=gr.themes.Soft()) as demo:
|
| 120 |
+
gr.Markdown(
|
| 121 |
+
"""
|
| 122 |
+
# MedicalAI - Light Weight
|
| 123 |
+
Upload a chest X-ray for AI-assisted analysis.
|
| 124 |
+
"""
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
with gr.Tab("Vision - Generate Report"):
|
| 128 |
+
gr.Markdown("Upload an X-ray and get an AI-generated radiology caption.")
|
| 129 |
+
with gr.Row():
|
| 130 |
+
img_in = gr.Image(label="X-ray Image", type="numpy")
|
| 131 |
+
with gr.Row():
|
| 132 |
+
btn_vision = gr.Button("Generate Report", variant="primary")
|
| 133 |
+
with gr.Row():
|
| 134 |
+
caption_out = gr.Textbox(label="Radiology Caption", lines=6)
|
| 135 |
+
|
| 136 |
+
btn_vision.click(fn=analyze_vision, inputs=img_in, outputs=caption_out)
|
| 137 |
+
|
| 138 |
+
with gr.Tab("Symptom Check - Diagnosis"):
|
| 139 |
+
gr.Markdown("Upload an X-ray, enter symptoms, and get a diagnosis.")
|
| 140 |
+
with gr.Row():
|
| 141 |
+
img_in2 = gr.Image(label="X-ray Image", type="numpy")
|
| 142 |
+
with gr.Row():
|
| 143 |
+
symptoms_in = gr.Textbox(
|
| 144 |
+
label="Patient Symptoms / Clinical Indication",
|
| 145 |
+
placeholder="e.g., shortness of breath, cough, fever...",
|
| 146 |
+
lines=3,
|
| 147 |
+
)
|
| 148 |
+
with gr.Row():
|
| 149 |
+
onnx_checkbox = gr.Checkbox(
|
| 150 |
+
label="Use ONNX (no PyTorch backend)",
|
| 151 |
+
value=has_onnx_full,
|
| 152 |
+
interactive=has_onnx_full,
|
| 153 |
+
)
|
| 154 |
+
with gr.Row():
|
| 155 |
+
btn_diag = gr.Button("Diagnose", variant="primary")
|
| 156 |
+
with gr.Row():
|
| 157 |
+
with gr.Column():
|
| 158 |
+
diag_out = gr.Textbox(label="Diagnosis", lines=4)
|
| 159 |
+
conf_out = gr.Textbox(label="Confidence")
|
| 160 |
+
|
| 161 |
+
btn_diag.click(
|
| 162 |
+
fn=analyze_symptom,
|
| 163 |
+
inputs=[img_in2, symptoms_in, onnx_checkbox],
|
| 164 |
+
outputs=[diag_out, conf_out],
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
with gr.Tab("System Info"):
|
| 168 |
+
gr.Markdown(f"""
|
| 169 |
+
**Device:** `{get_device().upper()}`
|
| 170 |
+
**Model:** `{model_source}`
|
| 171 |
+
**Trained checkpoint:** {'Yes' if has_trained else 'No'}
|
| 172 |
+
**ONNX full pipeline:** {'Yes' if has_onnx_full else 'No'}
|
| 173 |
+
**Default model:** {'Yes' if has_default else 'No'}
|
| 174 |
+
**Dataset:** `{os.path.abspath('./data/dataset.csv')}`
|
| 175 |
+
|
| 176 |
+
### 3 ways to improve accuracy
|
| 177 |
+
1. **Train** — `python training.py --mode prepare-data && python training.py --mode train`
|
| 178 |
+
2. **Update** — `python update.py --models` (downloads pre-trained model from Hugging Face)
|
| 179 |
+
3. **Export to ONNX** — `python quantization.py --mode export-full`
|
| 180 |
+
""")
|
| 181 |
+
|
| 182 |
+
demo.launch(server_name="127.0.0.1", server_port=7860, share=False)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
if __name__ == "__main__":
|
| 186 |
+
main()
|
xray_training.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
from PIL import Image
|
| 6 |
+
from torch.utils.data import Dataset, DataLoader, random_split
|
| 7 |
+
from tqdm import tqdm
|
| 8 |
+
|
| 9 |
+
MODEL_DIR = "./blip-xray-finetuned"
|
| 10 |
+
CHECKPOINT_PATH = os.path.join(MODEL_DIR, "xray_blip.pth")
|
| 11 |
+
ONNX_PATH = os.path.join(MODEL_DIR, "onnx")
|
| 12 |
+
|
| 13 |
+
class RadiologyCaptionDataset(Dataset):
|
| 14 |
+
def __init__(self, hf_dataset, max_samples=None):
|
| 15 |
+
self.data = []
|
| 16 |
+
for i, example in enumerate(hf_dataset):
|
| 17 |
+
if max_samples and i >= max_samples:
|
| 18 |
+
break
|
| 19 |
+
image = example.get("image")
|
| 20 |
+
caption = example.get("caption", "").strip()
|
| 21 |
+
if image is None or not caption:
|
| 22 |
+
continue
|
| 23 |
+
if image.mode != "RGB":
|
| 24 |
+
image = image.convert("RGB")
|
| 25 |
+
self.data.append((image, caption))
|
| 26 |
+
|
| 27 |
+
def __len__(self):
|
| 28 |
+
return len(self.data)
|
| 29 |
+
|
| 30 |
+
def __getitem__(self, idx):
|
| 31 |
+
return self.data[idx]
|
| 32 |
+
|
| 33 |
+
def collate_fn(batch, processor):
|
| 34 |
+
images = [item[0] for item in batch]
|
| 35 |
+
captions = [item[1] for item in batch]
|
| 36 |
+
encoding = processor(
|
| 37 |
+
images=images, text=captions, return_tensors="pt", padding=True, truncation=True, max_length=128
|
| 38 |
+
)
|
| 39 |
+
encoding["labels"] = encoding["input_ids"].clone()
|
| 40 |
+
return encoding
|
| 41 |
+
|
| 42 |
+
def train(epochs, batch_size, lr, max_samples, resume, val_split, use_amp, grad_accum):
|
| 43 |
+
from transformers import BlipProcessor, BlipForConditionalGeneration
|
| 44 |
+
from datasets import load_dataset
|
| 45 |
+
|
| 46 |
+
has_gpu = torch.cuda.is_available()
|
| 47 |
+
use_amp = use_amp and has_gpu
|
| 48 |
+
scaler = torch.cuda.amp.GradScaler() if use_amp else None
|
| 49 |
+
|
| 50 |
+
hf_ds = load_dataset("eltorio/ROCOv2-radiology", split="train", streaming=True)
|
| 51 |
+
processor = BlipProcessor.from_pretrained("Salesforce/blip-image-captioning-base")
|
| 52 |
+
|
| 53 |
+
if resume and os.path.exists(CHECKPOINT_PATH):
|
| 54 |
+
print(f"Resuming from checkpoint: {CHECKPOINT_PATH}")
|
| 55 |
+
model = BlipForConditionalGeneration.from_pretrained(MODEL_DIR)
|
| 56 |
+
start_epoch = torch.load(CHECKPOINT_PATH, weights_only=False).get("epoch", 0) + 1
|
| 57 |
+
else:
|
| 58 |
+
model = BlipForConditionalGeneration.from_pretrained("Salesforce/blip-image-captioning-base")
|
| 59 |
+
start_epoch = 0
|
| 60 |
+
|
| 61 |
+
if has_gpu:
|
| 62 |
+
model = model.cuda()
|
| 63 |
+
|
| 64 |
+
from functools import partial
|
| 65 |
+
_collate = partial(collate_fn, processor=processor)
|
| 66 |
+
|
| 67 |
+
full_dataset = RadiologyCaptionDataset(hf_ds, max_samples)
|
| 68 |
+
val_size = max(int(val_split * len(full_dataset)), 1)
|
| 69 |
+
train_size = len(full_dataset) - val_size
|
| 70 |
+
train_subset, val_subset = random_split(full_dataset, [train_size, val_size])
|
| 71 |
+
|
| 72 |
+
train_loader = DataLoader(train_subset, batch_size=batch_size, shuffle=True, collate_fn=_collate)
|
| 73 |
+
val_loader = DataLoader(val_subset, batch_size=batch_size, shuffle=False, collate_fn=_collate)
|
| 74 |
+
print(f"Dataset: {len(train_subset)} train + {len(val_subset)} val samples")
|
| 75 |
+
print(f"Device: {'GPU' if has_gpu else 'CPU'} AMP: {'ON' if use_amp else 'OFF'} Grad accum: {grad_accum}")
|
| 76 |
+
|
| 77 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
|
| 78 |
+
model.train()
|
| 79 |
+
|
| 80 |
+
for epoch in range(start_epoch, epochs):
|
| 81 |
+
print(f"\n{'='*40}")
|
| 82 |
+
print(f" Epoch {epoch + 1}/{epochs}")
|
| 83 |
+
print(f"{'='*40}")
|
| 84 |
+
|
| 85 |
+
# ── Train ──
|
| 86 |
+
total_loss = 0.0
|
| 87 |
+
optimizer.zero_grad()
|
| 88 |
+
pbar = tqdm(train_loader, desc=f" Train")
|
| 89 |
+
for i, batch in enumerate(pbar):
|
| 90 |
+
pixel_values = batch.get("pixel_values")
|
| 91 |
+
input_ids = batch.get("input_ids")
|
| 92 |
+
attention_mask = batch.get("attention_mask")
|
| 93 |
+
labels = batch.get("labels")
|
| 94 |
+
|
| 95 |
+
if has_gpu:
|
| 96 |
+
pixel_values = pixel_values.cuda()
|
| 97 |
+
input_ids = input_ids.cuda()
|
| 98 |
+
attention_mask = attention_mask.cuda()
|
| 99 |
+
labels = labels.cuda()
|
| 100 |
+
|
| 101 |
+
with torch.amp.autocast("cuda", enabled=use_amp):
|
| 102 |
+
outputs = model(
|
| 103 |
+
pixel_values=pixel_values,
|
| 104 |
+
input_ids=input_ids,
|
| 105 |
+
attention_mask=attention_mask,
|
| 106 |
+
labels=labels,
|
| 107 |
+
)
|
| 108 |
+
loss = outputs.loss / grad_accum
|
| 109 |
+
|
| 110 |
+
if use_amp:
|
| 111 |
+
scaler.scale(loss).backward()
|
| 112 |
+
else:
|
| 113 |
+
loss.backward()
|
| 114 |
+
|
| 115 |
+
if (i + 1) % grad_accum == 0 or (i + 1) == len(train_loader):
|
| 116 |
+
if use_amp:
|
| 117 |
+
scaler.step(optimizer)
|
| 118 |
+
scaler.update()
|
| 119 |
+
else:
|
| 120 |
+
optimizer.step()
|
| 121 |
+
optimizer.zero_grad()
|
| 122 |
+
|
| 123 |
+
total_loss += loss.item() * grad_accum
|
| 124 |
+
pbar.set_postfix(loss=f"{loss.item() * grad_accum:.4f}")
|
| 125 |
+
|
| 126 |
+
avg_train_loss = total_loss / len(train_loader)
|
| 127 |
+
|
| 128 |
+
# ── Validation ──
|
| 129 |
+
model.eval()
|
| 130 |
+
val_loss = 0.0
|
| 131 |
+
with torch.no_grad():
|
| 132 |
+
for batch in tqdm(val_loader, desc=f" Val"):
|
| 133 |
+
pixel_values = batch.get("pixel_values")
|
| 134 |
+
input_ids = batch.get("input_ids")
|
| 135 |
+
attention_mask = batch.get("attention_mask")
|
| 136 |
+
labels = batch.get("labels")
|
| 137 |
+
outputs = model(
|
| 138 |
+
pixel_values=pixel_values,
|
| 139 |
+
input_ids=input_ids,
|
| 140 |
+
attention_mask=attention_mask,
|
| 141 |
+
labels=labels,
|
| 142 |
+
)
|
| 143 |
+
val_loss += outputs.loss.item()
|
| 144 |
+
model.train()
|
| 145 |
+
|
| 146 |
+
avg_val_loss = val_loss / len(val_loader)
|
| 147 |
+
print(f" Train loss: {avg_train_loss:.4f} | Val loss: {avg_val_loss:.4f}")
|
| 148 |
+
|
| 149 |
+
os.makedirs(MODEL_DIR, exist_ok=True)
|
| 150 |
+
model.save_pretrained(MODEL_DIR)
|
| 151 |
+
processor.save_pretrained(MODEL_DIR)
|
| 152 |
+
torch.save({"epoch": epoch}, CHECKPOINT_PATH)
|
| 153 |
+
print(f" Checkpoint saved to {MODEL_DIR}")
|
| 154 |
+
|
| 155 |
+
print(f"\nTraining complete. Model saved to {MODEL_DIR}")
|
| 156 |
+
|
| 157 |
+
def evaluate(batch_size, max_samples):
|
| 158 |
+
from transformers import BlipProcessor, BlipForConditionalGeneration
|
| 159 |
+
from datasets import load_dataset
|
| 160 |
+
|
| 161 |
+
if not os.path.exists(MODEL_DIR):
|
| 162 |
+
print(f"No model found at {MODEL_DIR}. Run training first.")
|
| 163 |
+
return
|
| 164 |
+
|
| 165 |
+
from functools import partial
|
| 166 |
+
print("Loading model and dataset...")
|
| 167 |
+
processor = BlipProcessor.from_pretrained(MODEL_DIR)
|
| 168 |
+
model = BlipForConditionalGeneration.from_pretrained(MODEL_DIR)
|
| 169 |
+
model.eval()
|
| 170 |
+
|
| 171 |
+
_collate = partial(collate_fn, processor=processor)
|
| 172 |
+
hf_ds = load_dataset("eltorio/ROCOv2-radiology", split="train", streaming=True)
|
| 173 |
+
dataset = RadiologyCaptionDataset(hf_ds, max_samples)
|
| 174 |
+
loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, collate_fn=_collate)
|
| 175 |
+
|
| 176 |
+
try:
|
| 177 |
+
from nltk.translate.bleu_score import corpus_bleu, SmoothingFunction
|
| 178 |
+
smoothie = SmoothingFunction().method4
|
| 179 |
+
except ImportError:
|
| 180 |
+
print("nltk not installed. Skipping BLEU evaluation.")
|
| 181 |
+
print("Install with: pip install nltk")
|
| 182 |
+
return
|
| 183 |
+
|
| 184 |
+
print(f"Evaluating on {len(dataset)} samples...")
|
| 185 |
+
references = []
|
| 186 |
+
hypotheses = []
|
| 187 |
+
total_loss = 0.0
|
| 188 |
+
|
| 189 |
+
with torch.no_grad():
|
| 190 |
+
for batch in tqdm(loader, desc="Evaluating"):
|
| 191 |
+
pixel_values = batch.get("pixel_values")
|
| 192 |
+
input_ids = batch.get("input_ids")
|
| 193 |
+
attention_mask = batch.get("attention_mask")
|
| 194 |
+
labels = batch.get("labels")
|
| 195 |
+
|
| 196 |
+
outputs = model(
|
| 197 |
+
pixel_values=pixel_values,
|
| 198 |
+
input_ids=input_ids,
|
| 199 |
+
attention_mask=attention_mask,
|
| 200 |
+
labels=labels,
|
| 201 |
+
)
|
| 202 |
+
total_loss += outputs.loss.item()
|
| 203 |
+
|
| 204 |
+
generated_ids = model.generate(pixel_values=pixel_values, max_length=64)
|
| 205 |
+
for i in range(len(labels)):
|
| 206 |
+
ref = processor.decode(labels[i], skip_special_tokens=True)
|
| 207 |
+
hyp = processor.decode(generated_ids[i], skip_special_tokens=True)
|
| 208 |
+
if ref and hyp:
|
| 209 |
+
references.append([ref.split()])
|
| 210 |
+
hypotheses.append(hyp.split())
|
| 211 |
+
|
| 212 |
+
avg_loss = total_loss / len(loader)
|
| 213 |
+
bleu = corpus_bleu(references, hypotheses, smoothing_function=smoothie)
|
| 214 |
+
print(f"Average loss: {avg_loss:.4f}")
|
| 215 |
+
print(f"Corpus BLEU: {bleu:.4f}")
|
| 216 |
+
|
| 217 |
+
print("\nSample generations:")
|
| 218 |
+
for i in range(min(5, len(references))):
|
| 219 |
+
ref = " ".join(references[i][0])
|
| 220 |
+
hyp = " ".join(hypotheses[i])
|
| 221 |
+
print(f" REF: {ref[:120]}")
|
| 222 |
+
print(f" HYP: {hyp[:120]}")
|
| 223 |
+
print()
|
| 224 |
+
|
| 225 |
+
def export_onnx():
|
| 226 |
+
print("[yellow]BLIP ONNX export requires a newer version of optimum.[/yellow]")
|
| 227 |
+
print("[yellow]Run: pip install --upgrade optimum[/yellow]")
|
| 228 |
+
print("[yellow]Until then, the system uses PyTorch directly (no speed difference for inference).[/yellow]")
|
| 229 |
+
|
| 230 |
+
def generate(image_path):
|
| 231 |
+
from transformers import BlipProcessor, BlipForConditionalGeneration
|
| 232 |
+
|
| 233 |
+
if not os.path.exists(MODEL_DIR):
|
| 234 |
+
print(f"No model found at {MODEL_DIR}. Run training first.")
|
| 235 |
+
return
|
| 236 |
+
|
| 237 |
+
print(f"Loading model from {MODEL_DIR}...")
|
| 238 |
+
processor = BlipProcessor.from_pretrained(MODEL_DIR)
|
| 239 |
+
model = BlipForConditionalGeneration.from_pretrained(MODEL_DIR)
|
| 240 |
+
model.eval()
|
| 241 |
+
|
| 242 |
+
image = Image.open(image_path).convert("RGB")
|
| 243 |
+
inputs = processor(images=image, return_tensors="pt")
|
| 244 |
+
with torch.no_grad():
|
| 245 |
+
out = model.generate(**inputs, max_length=64)
|
| 246 |
+
caption = processor.decode(out[0], skip_special_tokens=True)
|
| 247 |
+
print(f"Generated caption: {caption}")
|
| 248 |
+
|
| 249 |
+
if __name__ == "__main__":
|
| 250 |
+
parser = argparse.ArgumentParser(description="Fine-tune BLIP for radiology caption generation")
|
| 251 |
+
parser.add_argument("--mode", required=True, choices=["train", "evaluate", "export-onnx", "generate"])
|
| 252 |
+
parser.add_argument("--epochs", type=int, default=3)
|
| 253 |
+
parser.add_argument("--batch_size", type=int, default=4)
|
| 254 |
+
parser.add_argument("--lr", type=float, default=5e-5)
|
| 255 |
+
parser.add_argument("--max_samples", type=int, default=500, help="Max samples for training/eval (remove to use all)")
|
| 256 |
+
parser.add_argument("--resume", action="store_true", help="Resume from last checkpoint")
|
| 257 |
+
parser.add_argument("--val_split", type=float, default=0.1, help="Fraction of data for validation")
|
| 258 |
+
parser.add_argument("--use_amp", action="store_true", help="Enable mixed precision (GPU only)")
|
| 259 |
+
parser.add_argument("--grad_accum", type=int, default=1, help="Gradient accumulation steps")
|
| 260 |
+
parser.add_argument("--image", help="Path to image for --mode generate")
|
| 261 |
+
args = parser.parse_args()
|
| 262 |
+
|
| 263 |
+
if args.mode == "train":
|
| 264 |
+
train(args.epochs, args.batch_size, args.lr, args.max_samples, args.resume, args.val_split, args.use_amp, args.grad_accum)
|
| 265 |
+
elif args.mode == "evaluate":
|
| 266 |
+
evaluate(args.batch_size, args.max_samples)
|
| 267 |
+
elif args.mode == "export-onnx":
|
| 268 |
+
export_onnx()
|
| 269 |
+
elif args.mode == "generate":
|
| 270 |
+
if not args.image:
|
| 271 |
+
print("--mode generate requires --image <path>")
|
| 272 |
+
else:
|
| 273 |
+
generate(args.image)
|