pymite6941 commited on
Commit
e93bfbd
·
verified ·
1 Parent(s): 1496b47

Upload models, ONNX exports and application code

Browse files
.gitattributes CHANGED
@@ -1,35 +1,11 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
 
3
  *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
 
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
- *.tgz filter=lfs diff=lfs merge=lfs -text
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)