Spaces:
Runtime error
Runtime error
Upload 15 files
Browse files- README.md +112 -9
- __init__.py +0 -0
- database.py +279 -0
- main.py +499 -0
- medical_coding.py +531 -0
- model_loader.py +212 -0
- preprocessing.py +263 -0
- report_generator.py +440 -0
- requirements.txt +43 -0
- run_backend.bat +7 -0
- run_frontend.bat +4 -0
- run_training.bat +7 -0
- setup.bat +32 -0
- ui.py +707 -0
- validation_module.py +375 -0
README.md
CHANGED
|
@@ -1,12 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Radiology AI Report Generation System v2.0
|
| 2 |
+
|
| 3 |
+
AI-powered radiology report generation using BLIP-2 vision + Phi-2 LLM fine-tuned on real radiology reports.
|
| 4 |
+
|
| 5 |
+
## What it does
|
| 6 |
+
- Upload any radiology image (X-ray, CT, MRI, Ultrasound, PET, Mammography, etc.)
|
| 7 |
+
- BLIP-2 reads the actual image and describes what it sees
|
| 8 |
+
- Phi-2 (fine-tuned on 227,835 real radiology reports) generates a structured report
|
| 9 |
+
- Outputs: FINDINGS, IMPRESSION, RECOMMENDATIONS + UMLS/ICD-10/SNOMED CT codes
|
| 10 |
+
|
| 11 |
+
## Requirements
|
| 12 |
+
- Windows 10/11
|
| 13 |
+
- Python 3.10
|
| 14 |
+
- NVIDIA GPU with 8GB+ VRAM (RTX 4060 or better)
|
| 15 |
+
- CUDA 12.8
|
| 16 |
+
- 20GB free disk space (for models)
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## Quick Start
|
| 21 |
+
|
| 22 |
+
### Step 1 — Setup (run once)
|
| 23 |
+
```
|
| 24 |
+
Double-click: setup.bat
|
| 25 |
+
```
|
| 26 |
+
This creates venv, installs all dependencies, verifies GPU.
|
| 27 |
+
|
| 28 |
+
### Step 2 — Fine-tune (run once, ~35 minutes)
|
| 29 |
+
```
|
| 30 |
+
Double-click: run_training.bat
|
| 31 |
+
```
|
| 32 |
+
Downloads unsloth/Radiology_mini (227,835 real reports) and fine-tunes Phi-2.
|
| 33 |
+
|
| 34 |
+
### Step 3 — Start the system
|
| 35 |
+
Terminal 1:
|
| 36 |
+
```
|
| 37 |
+
run_backend.bat
|
| 38 |
+
```
|
| 39 |
+
Terminal 2:
|
| 40 |
+
```
|
| 41 |
+
run_frontend.bat
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
### Step 4 — Use the system
|
| 45 |
+
Open browser: http://localhost:7860
|
| 46 |
+
|
| 47 |
---
|
| 48 |
+
|
| 49 |
+
## Manual commands (if .bat files don't work)
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
cd D:\radiology_ai_system
|
| 53 |
+
venv\Scripts\activate
|
| 54 |
+
|
| 55 |
+
# Backend
|
| 56 |
+
set USE_4BIT=true
|
| 57 |
+
set ENABLE_VISION_CAPTION=true
|
| 58 |
+
uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
|
| 59 |
+
|
| 60 |
+
# Frontend (new terminal)
|
| 61 |
+
venv\Scripts\activate
|
| 62 |
+
python frontend/ui.py
|
| 63 |
+
|
| 64 |
+
# Fine-tuning (Git Bash)
|
| 65 |
+
python training/train_unsloth.py --max_steps 300
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## Project Structure
|
| 71 |
+
```
|
| 72 |
+
radiology_ai_system/
|
| 73 |
+
├── backend/
|
| 74 |
+
│ ├── main.py # FastAPI endpoints
|
| 75 |
+
│ ├── model_loader.py # BLIP-2 + Phi-2 loading
|
| 76 |
+
│ ├── preprocessing.py # Image preprocessing (9 modalities)
|
| 77 |
+
│ ├── report_generator.py # Report generation + parsing
|
| 78 |
+
│ ├── validation_module.py # Report quality scoring
|
| 79 |
+
│ └── medical_coding.py # UMLS/ICD-10/SNOMED CT
|
| 80 |
+
├── frontend/
|
| 81 |
+
│ └── ui.py # Gradio UI
|
| 82 |
+
├── training/
|
| 83 |
+
│ ├── train_unsloth.py # Fine-tuning on Radiology_mini
|
| 84 |
+
│ ├── prepare_dataset.py # Dataset preparation
|
| 85 |
+
│ └── evaluate_model.py # Model evaluation
|
| 86 |
+
├── models/
|
| 87 |
+
│ ├── slm_model/ # Phi-2 cached here
|
| 88 |
+
│ │ └── lora_adapter/ # Fine-tuned LoRA adapter saved here
|
| 89 |
+
│ └── vision_encoder/ # BLIP-2 cached here
|
| 90 |
+
├── setup.bat # One-time setup
|
| 91 |
+
├── run_backend.bat # Start backend
|
| 92 |
+
├── run_frontend.bat # Start frontend
|
| 93 |
+
├── run_training.bat # Fine-tune model
|
| 94 |
+
└── requirements.txt
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## VRAM Usage
|
| 100 |
+
| Component | VRAM |
|
| 101 |
+
|---------------|--------|
|
| 102 |
+
| Phi-2 (4-bit) | ~2.0GB |
|
| 103 |
+
| BLIP-2 | ~3.5GB |
|
| 104 |
+
| Total | ~5.5GB |
|
| 105 |
+
| Available | 8.6GB ✅ |
|
| 106 |
+
|
| 107 |
---
|
| 108 |
|
| 109 |
+
## API Endpoints
|
| 110 |
+
- POST /api/generate-report — Generate report from image
|
| 111 |
+
- GET /api/report/{id} — Get stored report
|
| 112 |
+
- GET /api/export/pdf/{id} — Download PDF
|
| 113 |
+
- GET /api/export/json/{id} — Download JSON
|
| 114 |
+
- GET /api/health — System health
|
| 115 |
+
- GET /docs — Swagger UI (http://localhost:8000/docs)
|
__init__.py
ADDED
|
File without changes
|
database.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
database.py
|
| 3 |
+
-----------
|
| 4 |
+
SQLite database for Radiology AI Report System.
|
| 5 |
+
Stores all generated reports with patient info, findings, and metadata.
|
| 6 |
+
|
| 7 |
+
Tables:
|
| 8 |
+
patients — patient demographics
|
| 9 |
+
reports — generated radiology reports
|
| 10 |
+
images — uploaded image metadata
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
import sqlite3
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
DB_PATH = Path("data/radiology.db")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_connection() -> sqlite3.Connection:
|
| 26 |
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
|
| 28 |
+
conn.row_factory = sqlite3.Row
|
| 29 |
+
conn.execute("PRAGMA journal_mode=WAL")
|
| 30 |
+
conn.execute("PRAGMA foreign_keys=ON")
|
| 31 |
+
return conn
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def init_db():
|
| 35 |
+
"""Create all tables if they don't exist."""
|
| 36 |
+
conn = get_connection()
|
| 37 |
+
try:
|
| 38 |
+
conn.executescript("""
|
| 39 |
+
CREATE TABLE IF NOT EXISTS patients (
|
| 40 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 41 |
+
patient_name TEXT NOT NULL,
|
| 42 |
+
age INTEGER NOT NULL,
|
| 43 |
+
gender TEXT NOT NULL,
|
| 44 |
+
created_at TEXT DEFAULT (datetime('now')),
|
| 45 |
+
updated_at TEXT DEFAULT (datetime('now'))
|
| 46 |
+
);
|
| 47 |
+
|
| 48 |
+
CREATE TABLE IF NOT EXISTS reports (
|
| 49 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 50 |
+
report_uuid TEXT NOT NULL UNIQUE,
|
| 51 |
+
patient_id INTEGER NOT NULL REFERENCES patients(id),
|
| 52 |
+
patient_name TEXT NOT NULL,
|
| 53 |
+
age INTEGER NOT NULL,
|
| 54 |
+
gender TEXT NOT NULL,
|
| 55 |
+
symptoms TEXT,
|
| 56 |
+
clinical_history TEXT,
|
| 57 |
+
modality TEXT,
|
| 58 |
+
body_region TEXT,
|
| 59 |
+
view TEXT,
|
| 60 |
+
examination_details TEXT,
|
| 61 |
+
clinical_information TEXT,
|
| 62 |
+
findings TEXT,
|
| 63 |
+
impression TEXT,
|
| 64 |
+
recommendations TEXT,
|
| 65 |
+
confidence_score REAL,
|
| 66 |
+
generation_time_sec REAL,
|
| 67 |
+
image_caption TEXT,
|
| 68 |
+
validation_json TEXT,
|
| 69 |
+
medical_coding_json TEXT,
|
| 70 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
CREATE INDEX IF NOT EXISTS idx_reports_uuid
|
| 74 |
+
ON reports(report_uuid);
|
| 75 |
+
CREATE INDEX IF NOT EXISTS idx_reports_patient
|
| 76 |
+
ON reports(patient_id);
|
| 77 |
+
CREATE INDEX IF NOT EXISTS idx_reports_created
|
| 78 |
+
ON reports(created_at);
|
| 79 |
+
CREATE INDEX IF NOT EXISTS idx_patients_name
|
| 80 |
+
ON patients(patient_name);
|
| 81 |
+
""")
|
| 82 |
+
conn.commit()
|
| 83 |
+
logger.info(f"Database initialised at {DB_PATH}")
|
| 84 |
+
finally:
|
| 85 |
+
conn.close()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 89 |
+
# Patient operations
|
| 90 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 91 |
+
|
| 92 |
+
def create_or_get_patient(name: str, age: int, gender: str) -> int:
|
| 93 |
+
"""
|
| 94 |
+
Creates a new patient record or returns existing ID
|
| 95 |
+
if same name+age+gender exists.
|
| 96 |
+
"""
|
| 97 |
+
conn = get_connection()
|
| 98 |
+
try:
|
| 99 |
+
row = conn.execute(
|
| 100 |
+
"SELECT id FROM patients WHERE patient_name=? AND age=? AND gender=?",
|
| 101 |
+
(name.strip(), age, gender)
|
| 102 |
+
).fetchone()
|
| 103 |
+
if row:
|
| 104 |
+
return row["id"]
|
| 105 |
+
cur = conn.execute(
|
| 106 |
+
"INSERT INTO patients (patient_name, age, gender) VALUES (?,?,?)",
|
| 107 |
+
(name.strip(), age, gender)
|
| 108 |
+
)
|
| 109 |
+
conn.commit()
|
| 110 |
+
return cur.lastrowid
|
| 111 |
+
finally:
|
| 112 |
+
conn.close()
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def search_patients(query: str) -> list:
|
| 116 |
+
"""Search patients by name."""
|
| 117 |
+
conn = get_connection()
|
| 118 |
+
try:
|
| 119 |
+
rows = conn.execute(
|
| 120 |
+
"SELECT DISTINCT patient_name, age, gender, COUNT(*) as report_count "
|
| 121 |
+
"FROM reports WHERE patient_name LIKE ? "
|
| 122 |
+
"GROUP BY patient_name, age, gender "
|
| 123 |
+
"ORDER BY created_at DESC LIMIT 20",
|
| 124 |
+
(f"%{query}%",)
|
| 125 |
+
).fetchall()
|
| 126 |
+
return [dict(r) for r in rows]
|
| 127 |
+
finally:
|
| 128 |
+
conn.close()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 132 |
+
# Report operations
|
| 133 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 134 |
+
|
| 135 |
+
def save_report(payload: dict, patient_name: str) -> int:
|
| 136 |
+
"""Save a complete report payload to the database. Returns row ID."""
|
| 137 |
+
patient = payload.get("patient", {})
|
| 138 |
+
imaging = payload.get("imaging", {})
|
| 139 |
+
report = payload.get("report", {})
|
| 140 |
+
valid = payload.get("validation", {})
|
| 141 |
+
coding = payload.get("medical_coding", {})
|
| 142 |
+
|
| 143 |
+
age = patient.get("age", 0)
|
| 144 |
+
gender = patient.get("gender", "Unknown")
|
| 145 |
+
|
| 146 |
+
patient_id = create_or_get_patient(patient_name, age, gender)
|
| 147 |
+
|
| 148 |
+
conn = get_connection()
|
| 149 |
+
try:
|
| 150 |
+
cur = conn.execute("""
|
| 151 |
+
INSERT INTO reports (
|
| 152 |
+
report_uuid, patient_id, patient_name, age, gender,
|
| 153 |
+
symptoms, clinical_history,
|
| 154 |
+
modality, body_region, view,
|
| 155 |
+
examination_details, clinical_information,
|
| 156 |
+
findings, impression, recommendations,
|
| 157 |
+
confidence_score, generation_time_sec, image_caption,
|
| 158 |
+
validation_json, medical_coding_json
|
| 159 |
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
| 160 |
+
""", (
|
| 161 |
+
payload.get("report_id", ""),
|
| 162 |
+
patient_id,
|
| 163 |
+
patient_name.strip(),
|
| 164 |
+
age,
|
| 165 |
+
gender,
|
| 166 |
+
patient.get("symptoms", ""),
|
| 167 |
+
patient.get("clinical_history", ""),
|
| 168 |
+
imaging.get("modality", ""),
|
| 169 |
+
imaging.get("body_region", ""),
|
| 170 |
+
imaging.get("view", ""),
|
| 171 |
+
report.get("examination_details", ""),
|
| 172 |
+
report.get("clinical_information", ""),
|
| 173 |
+
report.get("findings", ""),
|
| 174 |
+
report.get("impression", ""),
|
| 175 |
+
report.get("recommendations", ""),
|
| 176 |
+
report.get("confidence_score", 0.0),
|
| 177 |
+
report.get("generation_time_seconds", 0.0),
|
| 178 |
+
payload.get("image_caption", ""),
|
| 179 |
+
json.dumps(valid),
|
| 180 |
+
json.dumps(coding),
|
| 181 |
+
))
|
| 182 |
+
conn.commit()
|
| 183 |
+
logger.info(f"Report saved — DB row: {cur.lastrowid} | patient: {patient_name}")
|
| 184 |
+
return cur.lastrowid
|
| 185 |
+
finally:
|
| 186 |
+
conn.close()
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def get_all_reports(limit: int = 50, offset: int = 0) -> list:
|
| 190 |
+
"""Get all reports ordered by newest first."""
|
| 191 |
+
conn = get_connection()
|
| 192 |
+
try:
|
| 193 |
+
rows = conn.execute("""
|
| 194 |
+
SELECT report_uuid, patient_name, age, gender,
|
| 195 |
+
modality, body_region, confidence_score,
|
| 196 |
+
created_at
|
| 197 |
+
FROM reports
|
| 198 |
+
ORDER BY created_at DESC
|
| 199 |
+
LIMIT ? OFFSET ?
|
| 200 |
+
""", (limit, offset)).fetchall()
|
| 201 |
+
return [dict(r) for r in rows]
|
| 202 |
+
finally:
|
| 203 |
+
conn.close()
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def get_report_by_uuid(uuid: str) -> Optional[dict]:
|
| 207 |
+
"""Get full report by UUID."""
|
| 208 |
+
conn = get_connection()
|
| 209 |
+
try:
|
| 210 |
+
row = conn.execute(
|
| 211 |
+
"SELECT * FROM reports WHERE report_uuid=?", (uuid,)
|
| 212 |
+
).fetchone()
|
| 213 |
+
if not row:
|
| 214 |
+
return None
|
| 215 |
+
result = dict(row)
|
| 216 |
+
# Parse JSON fields
|
| 217 |
+
for field in ("validation_json", "medical_coding_json"):
|
| 218 |
+
try:
|
| 219 |
+
result[field] = json.loads(result[field] or "{}")
|
| 220 |
+
except Exception:
|
| 221 |
+
result[field] = {}
|
| 222 |
+
return result
|
| 223 |
+
finally:
|
| 224 |
+
conn.close()
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def get_reports_by_patient(patient_name: str) -> list:
|
| 228 |
+
"""Get all reports for a specific patient."""
|
| 229 |
+
conn = get_connection()
|
| 230 |
+
try:
|
| 231 |
+
rows = conn.execute("""
|
| 232 |
+
SELECT report_uuid, patient_name, age, gender,
|
| 233 |
+
modality, body_region, confidence_score,
|
| 234 |
+
findings, impression, created_at
|
| 235 |
+
FROM reports
|
| 236 |
+
WHERE patient_name LIKE ?
|
| 237 |
+
ORDER BY created_at DESC
|
| 238 |
+
""", (f"%{patient_name}%",)).fetchall()
|
| 239 |
+
return [dict(r) for r in rows]
|
| 240 |
+
finally:
|
| 241 |
+
conn.close()
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def get_db_stats() -> dict:
|
| 245 |
+
"""Get database statistics."""
|
| 246 |
+
conn = get_connection()
|
| 247 |
+
try:
|
| 248 |
+
total_reports = conn.execute("SELECT COUNT(*) FROM reports").fetchone()[0]
|
| 249 |
+
total_patients = conn.execute("SELECT COUNT(DISTINCT patient_name) FROM reports").fetchone()[0]
|
| 250 |
+
avg_confidence = conn.execute("SELECT AVG(confidence_score) FROM reports").fetchone()[0]
|
| 251 |
+
modality_counts = conn.execute(
|
| 252 |
+
"SELECT modality, COUNT(*) as cnt FROM reports GROUP BY modality ORDER BY cnt DESC"
|
| 253 |
+
).fetchall()
|
| 254 |
+
return {
|
| 255 |
+
"total_reports": total_reports,
|
| 256 |
+
"total_patients": total_patients,
|
| 257 |
+
"avg_confidence": round(avg_confidence or 0, 2),
|
| 258 |
+
"modality_counts": {r["modality"]: r["cnt"] for r in modality_counts},
|
| 259 |
+
}
|
| 260 |
+
finally:
|
| 261 |
+
conn.close()
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def delete_report(uuid: str) -> bool:
|
| 265 |
+
"""Delete a report by UUID."""
|
| 266 |
+
conn = get_connection()
|
| 267 |
+
try:
|
| 268 |
+
conn.execute("DELETE FROM reports WHERE report_uuid=?", (uuid,))
|
| 269 |
+
conn.commit()
|
| 270 |
+
return True
|
| 271 |
+
except Exception as e:
|
| 272 |
+
logger.error(f"Delete failed: {e}")
|
| 273 |
+
return False
|
| 274 |
+
finally:
|
| 275 |
+
conn.close()
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# Auto-initialise on import
|
| 279 |
+
init_db()
|
main.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
main.py — FastAPI backend for Radiology AI Report Generation
|
| 3 |
+
Vision is ENABLED by default (BLIP-2 reads the actual image).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import io
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import uuid
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 17 |
+
if str(ROOT) not in sys.path:
|
| 18 |
+
sys.path.insert(0, str(ROOT))
|
| 19 |
+
|
| 20 |
+
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
| 21 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 22 |
+
from fastapi.responses import JSONResponse, Response
|
| 23 |
+
|
| 24 |
+
# Database
|
| 25 |
+
import importlib.util as _ilu
|
| 26 |
+
_db_spec = _ilu.spec_from_file_location("backend.database",
|
| 27 |
+
__file__.replace("main.py","database.py"))
|
| 28 |
+
_db_mod = _ilu.module_from_spec(_db_spec)
|
| 29 |
+
_db_spec.loader.exec_module(_db_mod)
|
| 30 |
+
db = _db_mod
|
| 31 |
+
|
| 32 |
+
logging.basicConfig(
|
| 33 |
+
level=logging.INFO,
|
| 34 |
+
format="%(asctime)s | %(levelname)-8s | %(name)s — %(message)s",
|
| 35 |
+
)
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
app = FastAPI(
|
| 39 |
+
title="Radiology AI Report Generation System",
|
| 40 |
+
version="2.0.0",
|
| 41 |
+
description="AI-powered radiology report generation with BLIP-2 vision + Phi-2 LLM",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
app.add_middleware(
|
| 45 |
+
CORSMiddleware,
|
| 46 |
+
allow_origins=["*"],
|
| 47 |
+
allow_credentials=True,
|
| 48 |
+
allow_methods=["*"],
|
| 49 |
+
allow_headers=["*"],
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
_report_store: dict = {}
|
| 53 |
+
_models_loaded = False
|
| 54 |
+
_slm_model = None
|
| 55 |
+
_slm_tokenizer = None
|
| 56 |
+
_vision_model = None
|
| 57 |
+
_vision_processor = None
|
| 58 |
+
|
| 59 |
+
# Vision is ON by default — set ENABLE_VISION_CAPTION=false to disable
|
| 60 |
+
ENABLE_VISION_CAPTION = os.getenv("ENABLE_VISION_CAPTION", "true").lower() == "true"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 64 |
+
# Module loader helper (avoids import path issues on Windows)
|
| 65 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 66 |
+
|
| 67 |
+
def _load_module(name, filepath):
|
| 68 |
+
import importlib.util
|
| 69 |
+
if name in sys.modules:
|
| 70 |
+
return sys.modules[name]
|
| 71 |
+
spec = importlib.util.spec_from_file_location(name, filepath)
|
| 72 |
+
mod = importlib.util.module_from_spec(spec)
|
| 73 |
+
sys.modules[name] = mod
|
| 74 |
+
spec.loader.exec_module(mod)
|
| 75 |
+
return mod
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
_BACKEND = Path(__file__).resolve().parent
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _ml():
|
| 82 |
+
return _load_module("backend.model_loader", _BACKEND / "model_loader.py")
|
| 83 |
+
def _pp():
|
| 84 |
+
return _load_module("backend.preprocessing", _BACKEND / "preprocessing.py")
|
| 85 |
+
def _rg():
|
| 86 |
+
return _load_module("backend.report_generator",_BACKEND / "report_generator.py")
|
| 87 |
+
def _vm():
|
| 88 |
+
return _load_module("backend.validation_module",_BACKEND / "validation_module.py")
|
| 89 |
+
def _mc():
|
| 90 |
+
return _load_module("backend.medical_coding", _BACKEND / "medical_coding.py")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 94 |
+
# Model loading
|
| 95 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 96 |
+
|
| 97 |
+
def _ensure_models_loaded():
|
| 98 |
+
global _models_loaded, _slm_model, _slm_tokenizer, _vision_model, _vision_processor
|
| 99 |
+
if _models_loaded:
|
| 100 |
+
return
|
| 101 |
+
|
| 102 |
+
logger.info("Loading AI models...")
|
| 103 |
+
ml = _ml()
|
| 104 |
+
|
| 105 |
+
# Always load language model
|
| 106 |
+
_slm_model, _slm_tokenizer = ml.load_slm()
|
| 107 |
+
|
| 108 |
+
# Load vision model (BLIP-2) if enabled
|
| 109 |
+
if ENABLE_VISION_CAPTION:
|
| 110 |
+
logger.info("Loading BLIP-2 vision model (this takes ~30s on first run)...")
|
| 111 |
+
try:
|
| 112 |
+
_vision_model, _vision_processor = ml.load_vision_encoder()
|
| 113 |
+
logger.info("BLIP-2 vision model loaded — image content will be analysed.")
|
| 114 |
+
except Exception as exc:
|
| 115 |
+
logger.warning(f"BLIP-2 failed to load: {exc}. Reports will use form-based captions only.")
|
| 116 |
+
|
| 117 |
+
_models_loaded = True
|
| 118 |
+
logger.info("All models loaded.")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@app.on_event("startup")
|
| 122 |
+
async def startup_event():
|
| 123 |
+
try:
|
| 124 |
+
_ensure_models_loaded()
|
| 125 |
+
except Exception as exc:
|
| 126 |
+
logger.error(f"Startup model loading failed: {exc}")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 130 |
+
# Health & Info
|
| 131 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 132 |
+
|
| 133 |
+
@app.get("/api/health", tags=["System"])
|
| 134 |
+
def health_check():
|
| 135 |
+
import torch
|
| 136 |
+
return {
|
| 137 |
+
"status": "healthy",
|
| 138 |
+
"models_loaded": _models_loaded,
|
| 139 |
+
"vision_enabled": ENABLE_VISION_CAPTION,
|
| 140 |
+
"vision_model_loaded": _vision_model is not None,
|
| 141 |
+
"device": "cuda" if torch.cuda.is_available() else "cpu",
|
| 142 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@app.get("/api/models", tags=["System"])
|
| 147 |
+
def list_models():
|
| 148 |
+
import torch
|
| 149 |
+
return {
|
| 150 |
+
"slm_model": os.getenv("SLM_MODEL_ID", "microsoft/phi-2"),
|
| 151 |
+
"vision_model": os.getenv("VISION_MODEL_ID", "Salesforce/blip2-opt-2.7b"),
|
| 152 |
+
"vision_enabled": ENABLE_VISION_CAPTION,
|
| 153 |
+
"vision_loaded": _vision_model is not None,
|
| 154 |
+
"device": "cuda" if torch.cuda.is_available() else "cpu",
|
| 155 |
+
"models_loaded": _models_loaded,
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 160 |
+
# Main report generation endpoint
|
| 161 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 162 |
+
|
| 163 |
+
@app.post("/api/generate-report", tags=["Report"])
|
| 164 |
+
async def generate_report(
|
| 165 |
+
image: UploadFile = File(...),
|
| 166 |
+
patient_name: str = Form(""),
|
| 167 |
+
patient_age: int = Form(...),
|
| 168 |
+
patient_gender: str = Form(...),
|
| 169 |
+
symptoms: str = Form(...),
|
| 170 |
+
clinical_history: str = Form(...),
|
| 171 |
+
modality: str = Form(...),
|
| 172 |
+
body_region: str = Form(...),
|
| 173 |
+
view: Optional[str] = Form(""),
|
| 174 |
+
):
|
| 175 |
+
# 1. Read image bytes
|
| 176 |
+
file_bytes = await image.read()
|
| 177 |
+
filename = image.filename or "upload.jpg"
|
| 178 |
+
logger.info(f"Image received: {filename} ({len(file_bytes)/1024:.1f} KB)")
|
| 179 |
+
|
| 180 |
+
# 2. Preprocess image
|
| 181 |
+
try:
|
| 182 |
+
pp = _pp()
|
| 183 |
+
preprocessor = pp.RadiologyPreprocessor(
|
| 184 |
+
modality=modality.lower(),
|
| 185 |
+
body_region=body_region.lower(),
|
| 186 |
+
)
|
| 187 |
+
pil_image = preprocessor.preprocess_pil(file_bytes, filename)
|
| 188 |
+
except Exception as exc:
|
| 189 |
+
logger.error(f"Preprocessing error: {exc}")
|
| 190 |
+
raise HTTPException(status_code=422, detail=f"Image preprocessing failed: {exc}")
|
| 191 |
+
|
| 192 |
+
# 3. Load models
|
| 193 |
+
try:
|
| 194 |
+
_ensure_models_loaded()
|
| 195 |
+
except Exception as exc:
|
| 196 |
+
raise HTTPException(status_code=503, detail=f"Model loading failed: {exc}")
|
| 197 |
+
|
| 198 |
+
# 4. Generate image caption using BLIP-2 (reads actual image pixels)
|
| 199 |
+
image_caption = (
|
| 200 |
+
f"{modality} image of the {body_region}. "
|
| 201 |
+
f"Patient presents with {symptoms}."
|
| 202 |
+
)
|
| 203 |
+
if ENABLE_VISION_CAPTION and _vision_model is not None:
|
| 204 |
+
try:
|
| 205 |
+
ml = _ml()
|
| 206 |
+
_, blip_caption = ml.extract_visual_features(
|
| 207 |
+
pil_image, _vision_model, _vision_processor
|
| 208 |
+
)
|
| 209 |
+
if blip_caption and len(blip_caption) > 20:
|
| 210 |
+
image_caption = blip_caption
|
| 211 |
+
logger.info(f"BLIP-2 caption used: {image_caption[:120]}")
|
| 212 |
+
except Exception as exc:
|
| 213 |
+
logger.warning(f"Vision captioning failed: {exc} — using fallback caption")
|
| 214 |
+
|
| 215 |
+
# 5. Generate report using Phi-2
|
| 216 |
+
try:
|
| 217 |
+
rg = _rg()
|
| 218 |
+
patient = rg.PatientInfo(
|
| 219 |
+
age=patient_age, gender=patient_gender,
|
| 220 |
+
symptoms=symptoms, clinical_history=clinical_history,
|
| 221 |
+
)
|
| 222 |
+
imaging = rg.ImagingInfo(
|
| 223 |
+
modality=modality, body_region=body_region, view=view or ""
|
| 224 |
+
)
|
| 225 |
+
generator = rg.ReportGenerator(
|
| 226 |
+
slm_model=_slm_model, slm_tokenizer=_slm_tokenizer
|
| 227 |
+
)
|
| 228 |
+
report = generator.generate(
|
| 229 |
+
patient=patient, imaging=imaging, image_caption=image_caption
|
| 230 |
+
)
|
| 231 |
+
logger.info(f"Report generated in {report.generation_time_seconds:.1f}s")
|
| 232 |
+
except Exception as exc:
|
| 233 |
+
logger.error(f"Report generation error: {exc}")
|
| 234 |
+
raise HTTPException(status_code=500, detail=f"Report generation failed: {exc}")
|
| 235 |
+
|
| 236 |
+
# 6. Validate
|
| 237 |
+
vm = _vm()
|
| 238 |
+
validation = vm.validate_report(report)
|
| 239 |
+
report.confidence_score = validation.confidence_score
|
| 240 |
+
|
| 241 |
+
# 7. Medical coding (UMLS / ICD-10 / SNOMED CT)
|
| 242 |
+
mc = _mc()
|
| 243 |
+
coding_result = mc.extract_medical_codes(
|
| 244 |
+
report_text=report.raw_text,
|
| 245 |
+
sections={
|
| 246 |
+
"findings": report.findings,
|
| 247 |
+
"impression": report.impression,
|
| 248 |
+
"recommendations": report.recommendations,
|
| 249 |
+
},
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
# 8. Build and store response
|
| 253 |
+
report_id = str(uuid.uuid4())
|
| 254 |
+
# Clean patient name
|
| 255 |
+
patient_name_clean = patient_name.strip() if patient_name.strip() else "Unknown Patient"
|
| 256 |
+
|
| 257 |
+
payload = {
|
| 258 |
+
"report_id": report_id,
|
| 259 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 260 |
+
"image_caption": image_caption,
|
| 261 |
+
"patient_name": patient_name_clean,
|
| 262 |
+
"patient": {
|
| 263 |
+
"name": patient_name_clean,
|
| 264 |
+
"age": patient_age,
|
| 265 |
+
"gender": patient_gender,
|
| 266 |
+
"symptoms": symptoms,
|
| 267 |
+
"clinical_history": clinical_history,
|
| 268 |
+
},
|
| 269 |
+
"imaging": {
|
| 270 |
+
"modality": modality,
|
| 271 |
+
"body_region": body_region,
|
| 272 |
+
"view": view,
|
| 273 |
+
},
|
| 274 |
+
"report": report.to_dict(),
|
| 275 |
+
"report_text": report.to_text(),
|
| 276 |
+
"validation": validation.to_dict(),
|
| 277 |
+
"medical_coding": coding_result.to_dict(),
|
| 278 |
+
}
|
| 279 |
+
_report_store[report_id] = payload
|
| 280 |
+
|
| 281 |
+
# Save to SQLite database
|
| 282 |
+
try:
|
| 283 |
+
db.save_report(payload, patient_name_clean)
|
| 284 |
+
except Exception as db_exc:
|
| 285 |
+
logger.warning(f"DB save failed (non-critical): {db_exc}")
|
| 286 |
+
|
| 287 |
+
logger.info(
|
| 288 |
+
f"Report stored: {report_id[:8]} | "
|
| 289 |
+
f"confidence: {report.confidence_score:.2f} | "
|
| 290 |
+
f"codes: {len(coding_result.codes)} | "
|
| 291 |
+
f"diagnosis: {coding_result.primary_diagnosis_label}"
|
| 292 |
+
)
|
| 293 |
+
return JSONResponse(content=payload)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 297 |
+
# Report retrieval & export
|
| 298 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 299 |
+
|
| 300 |
+
@app.get("/api/report/{report_id}", tags=["Report"])
|
| 301 |
+
def get_report(report_id: str):
|
| 302 |
+
if report_id not in _report_store:
|
| 303 |
+
raise HTTPException(status_code=404, detail="Report not found.")
|
| 304 |
+
return JSONResponse(content=_report_store[report_id])
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
@app.get("/api/export/json/{report_id}", tags=["Export"])
|
| 308 |
+
def export_json(report_id: str):
|
| 309 |
+
if report_id not in _report_store:
|
| 310 |
+
raise HTTPException(status_code=404, detail="Report not found.")
|
| 311 |
+
return Response(
|
| 312 |
+
content=json.dumps(_report_store[report_id], indent=2).encode(),
|
| 313 |
+
media_type="application/json",
|
| 314 |
+
headers={"Content-Disposition": f'attachment; filename="report_{report_id[:8]}.json"'},
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
@app.get("/api/export/pdf/{report_id}", tags=["Export"])
|
| 319 |
+
def export_pdf(report_id: str):
|
| 320 |
+
if report_id not in _report_store:
|
| 321 |
+
raise HTTPException(status_code=404, detail="Report not found.")
|
| 322 |
+
try:
|
| 323 |
+
pdf_bytes = _generate_pdf(_report_store[report_id])
|
| 324 |
+
except Exception as exc:
|
| 325 |
+
raise HTTPException(status_code=500, detail=f"PDF generation failed: {exc}")
|
| 326 |
+
return Response(
|
| 327 |
+
content=pdf_bytes,
|
| 328 |
+
media_type="application/pdf",
|
| 329 |
+
headers={"Content-Disposition": f'attachment; filename="report_{report_id[:8]}.pdf"'},
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 334 |
+
# PDF generator
|
| 335 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 340 |
+
# Database endpoints
|
| 341 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 342 |
+
|
| 343 |
+
@app.get("/api/reports", tags=["Database"])
|
| 344 |
+
def list_all_reports(limit: int = 50, offset: int = 0):
|
| 345 |
+
"""List all stored reports from database."""
|
| 346 |
+
try:
|
| 347 |
+
reports = db.get_all_reports(limit=limit, offset=offset)
|
| 348 |
+
return JSONResponse(content={"reports": reports, "count": len(reports)})
|
| 349 |
+
except Exception as e:
|
| 350 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
@app.get("/api/reports/patient/{patient_name}", tags=["Database"])
|
| 354 |
+
def get_patient_reports(patient_name: str):
|
| 355 |
+
"""Get all reports for a specific patient."""
|
| 356 |
+
try:
|
| 357 |
+
reports = db.get_reports_by_patient(patient_name)
|
| 358 |
+
return JSONResponse(content={"patient": patient_name,
|
| 359 |
+
"reports": reports,
|
| 360 |
+
"count": len(reports)})
|
| 361 |
+
except Exception as e:
|
| 362 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
@app.get("/api/reports/search", tags=["Database"])
|
| 366 |
+
def search_patients(q: str = ""):
|
| 367 |
+
"""Search patients by name."""
|
| 368 |
+
try:
|
| 369 |
+
results = db.search_patients(q)
|
| 370 |
+
return JSONResponse(content={"results": results})
|
| 371 |
+
except Exception as e:
|
| 372 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
@app.get("/api/stats", tags=["Database"])
|
| 376 |
+
def get_stats():
|
| 377 |
+
"""Get database statistics."""
|
| 378 |
+
try:
|
| 379 |
+
return JSONResponse(content=db.get_db_stats())
|
| 380 |
+
except Exception as e:
|
| 381 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
@app.delete("/api/reports/{report_id}", tags=["Database"])
|
| 385 |
+
def delete_report(report_id: str):
|
| 386 |
+
"""Delete a report by ID."""
|
| 387 |
+
try:
|
| 388 |
+
success = db.delete_report(report_id)
|
| 389 |
+
if not success:
|
| 390 |
+
raise HTTPException(status_code=404, detail="Report not found.")
|
| 391 |
+
return JSONResponse(content={"deleted": report_id})
|
| 392 |
+
except HTTPException:
|
| 393 |
+
raise
|
| 394 |
+
except Exception as e:
|
| 395 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 396 |
+
|
| 397 |
+
def _generate_pdf(payload: dict) -> bytes:
|
| 398 |
+
from reportlab.lib import colors
|
| 399 |
+
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
|
| 400 |
+
from reportlab.lib.pagesizes import A4
|
| 401 |
+
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
| 402 |
+
from reportlab.lib.units import cm
|
| 403 |
+
from reportlab.platypus import (HRFlowable, Paragraph, SimpleDocTemplate,
|
| 404 |
+
Spacer, Table, TableStyle)
|
| 405 |
+
|
| 406 |
+
buf = io.BytesIO()
|
| 407 |
+
doc = SimpleDocTemplate(buf, pagesize=A4,
|
| 408 |
+
rightMargin=2*cm, leftMargin=2*cm,
|
| 409 |
+
topMargin=2.5*cm, bottomMargin=2*cm)
|
| 410 |
+
styles = getSampleStyleSheet()
|
| 411 |
+
BLUE = colors.HexColor("#1a4a8a")
|
| 412 |
+
LIGHT = colors.HexColor("#e8f0fb")
|
| 413 |
+
|
| 414 |
+
story = []
|
| 415 |
+
story.append(Paragraph("RADIOLOGY REPORT",
|
| 416 |
+
ParagraphStyle("T", parent=styles["Title"], textColor=BLUE,
|
| 417 |
+
fontSize=20, spaceAfter=4, fontName="Helvetica-Bold")))
|
| 418 |
+
|
| 419 |
+
report = payload.get("report", {})
|
| 420 |
+
patient = payload.get("patient", {})
|
| 421 |
+
imaging = payload.get("imaging", {})
|
| 422 |
+
validation = payload.get("validation", {})
|
| 423 |
+
report_id = payload.get("report_id", "N/A")
|
| 424 |
+
timestamp = payload.get("timestamp", "")
|
| 425 |
+
|
| 426 |
+
story.append(Paragraph(
|
| 427 |
+
f"AI-Generated Report | {timestamp[:10]} | ID: {report_id[:8]}",
|
| 428 |
+
ParagraphStyle("S", parent=styles["Normal"], textColor=colors.HexColor("#555"),
|
| 429 |
+
fontSize=10, spaceAfter=12, alignment=TA_CENTER)))
|
| 430 |
+
story.append(HRFlowable(width="100%", thickness=2, color=BLUE))
|
| 431 |
+
story.append(Spacer(1, 0.4*cm))
|
| 432 |
+
|
| 433 |
+
cell_style = ParagraphStyle("cell", parent=styles["Normal"], fontSize=9,
|
| 434 |
+
leading=12)
|
| 435 |
+
lbl_style = ParagraphStyle("lbl", parent=styles["Normal"], fontSize=9,
|
| 436 |
+
leading=12, fontName="Helvetica-Bold")
|
| 437 |
+
symptoms_text = str(patient.get("symptoms", "—"))
|
| 438 |
+
|
| 439 |
+
tbl = Table([
|
| 440 |
+
[Paragraph("Patient Name", lbl_style), Paragraph(str(payload.get("patient_name","—")), cell_style),
|
| 441 |
+
Paragraph("Report ID", lbl_style), Paragraph(str(report_id[:8]), cell_style)],
|
| 442 |
+
[Paragraph("Patient Age", lbl_style), Paragraph(str(patient.get("age","—")), cell_style),
|
| 443 |
+
Paragraph("Modality", lbl_style), Paragraph(str(imaging.get("modality","—")), cell_style)],
|
| 444 |
+
[Paragraph("Gender", lbl_style), Paragraph(str(patient.get("gender","—")), cell_style),
|
| 445 |
+
Paragraph("Body Region", lbl_style), Paragraph(str(imaging.get("body_region","—")), cell_style)],
|
| 446 |
+
[Paragraph("View", lbl_style), Paragraph(str(imaging.get("view","—") or "—"), cell_style),
|
| 447 |
+
Paragraph("", cell_style), Paragraph("", cell_style)],
|
| 448 |
+
[Paragraph("Symptoms", lbl_style), Paragraph(symptoms_text, cell_style),
|
| 449 |
+
Paragraph("", cell_style), Paragraph("", cell_style)],
|
| 450 |
+
], colWidths=[3*cm, 7*cm, 3*cm, 4*cm])
|
| 451 |
+
tbl.setStyle(TableStyle([
|
| 452 |
+
("FONTSIZE", (0,0),(-1,-1), 9),
|
| 453 |
+
("BACKGROUND", (0,0),(-1,0), LIGHT),
|
| 454 |
+
("BACKGROUND", (0,2),(-1,2), LIGHT),
|
| 455 |
+
("GRID", (0,0),(-1,-1), 0.5, colors.HexColor("#ccc")),
|
| 456 |
+
("VALIGN", (0,0),(-1,-1), "TOP"),
|
| 457 |
+
("TOPPADDING", (0,0),(-1,-1), 5),
|
| 458 |
+
("BOTTOMPADDING", (0,0),(-1,-1), 5),
|
| 459 |
+
("SPAN", (1,2),(3,2)),
|
| 460 |
+
("SPAN", (1,3),(3,3)),
|
| 461 |
+
]))
|
| 462 |
+
story.append(tbl)
|
| 463 |
+
story.append(Spacer(1, 0.5*cm))
|
| 464 |
+
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#aaa")))
|
| 465 |
+
|
| 466 |
+
hdr = ParagraphStyle("H", parent=styles["Heading2"], textColor=BLUE,
|
| 467 |
+
fontSize=11, fontName="Helvetica-Bold", spaceBefore=12, spaceAfter=4)
|
| 468 |
+
body = ParagraphStyle("B", parent=styles["Normal"], fontSize=10,
|
| 469 |
+
leading=15, spaceAfter=6, alignment=TA_JUSTIFY)
|
| 470 |
+
|
| 471 |
+
for header, key in [
|
| 472 |
+
("Examination Details", "examination_details"),
|
| 473 |
+
("Clinical Information", "clinical_information"),
|
| 474 |
+
("Findings", "findings"),
|
| 475 |
+
("Impression", "impression"),
|
| 476 |
+
("Recommendations", "recommendations"),
|
| 477 |
+
]:
|
| 478 |
+
story.append(Paragraph(header.upper(), hdr))
|
| 479 |
+
story.append(Paragraph(str(report.get(key,"—") or "—"), body))
|
| 480 |
+
|
| 481 |
+
story.append(Spacer(1, 0.5*cm))
|
| 482 |
+
story.append(HRFlowable(width="100%", thickness=1, color=BLUE))
|
| 483 |
+
confidence = validation.get("confidence_score", 0)
|
| 484 |
+
story.append(Paragraph(
|
| 485 |
+
f"CONFIDENCE SCORE: {confidence:.2f} / 1.00",
|
| 486 |
+
ParagraphStyle("C", parent=styles["Normal"], fontSize=12,
|
| 487 |
+
fontName="Helvetica-Bold", textColor=BLUE,
|
| 488 |
+
alignment=TA_CENTER, spaceBefore=10)))
|
| 489 |
+
|
| 490 |
+
story.append(Spacer(1, 1*cm))
|
| 491 |
+
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#aaa")))
|
| 492 |
+
story.append(Paragraph(
|
| 493 |
+
"This report was generated by an AI system. "
|
| 494 |
+
"Clinical correlation by a licensed radiologist is mandatory.",
|
| 495 |
+
ParagraphStyle("D", parent=styles["Normal"], fontSize=8,
|
| 496 |
+
textColor=colors.HexColor("#888"),
|
| 497 |
+
alignment=TA_CENTER, spaceBefore=6)))
|
| 498 |
+
doc.build(story)
|
| 499 |
+
return buf.getvalue()
|
medical_coding.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
medical_coding.py
|
| 3 |
+
-----------------
|
| 4 |
+
UMLS / ICD-10 / SNOMED medical concept extraction and coding.
|
| 5 |
+
|
| 6 |
+
Extracts medical entities from radiology report text and maps them to:
|
| 7 |
+
- UMLS CUIs (e.g. C1306645 → Pneumonia)
|
| 8 |
+
- ICD-10 codes (e.g. J18.9 → Pneumonia, unspecified)
|
| 9 |
+
- SNOMED CT codes
|
| 10 |
+
- Severity indicators
|
| 11 |
+
- Anatomical locations
|
| 12 |
+
|
| 13 |
+
Works WITHOUT scispacy (uses built-in dictionary for reliability).
|
| 14 |
+
Optionally uses scispacy if installed for deeper NLP extraction.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import re
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
+
from typing import Dict, List, Optional, Tuple
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# UMLS + ICD-10 Medical Concept Dictionary
|
| 27 |
+
# Covers the most common radiology findings
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
MEDICAL_CONCEPT_DB: Dict[str, dict] = {
|
| 31 |
+
|
| 32 |
+
# ── Pulmonary ──────────────────────────────────────────────────────────
|
| 33 |
+
"pneumonia": {
|
| 34 |
+
"cui": "C0032285", "icd10": "J18.9", "snomed": "233604007",
|
| 35 |
+
"label": "Pneumonia", "category": "Pulmonary",
|
| 36 |
+
"severity_weight": 0.7
|
| 37 |
+
},
|
| 38 |
+
"consolidation": {
|
| 39 |
+
"cui": "C0521530", "icd10": "J98.19", "snomed": "44416002",
|
| 40 |
+
"label": "Pulmonary Consolidation", "category": "Pulmonary",
|
| 41 |
+
"severity_weight": 0.6
|
| 42 |
+
},
|
| 43 |
+
"pneumothorax": {
|
| 44 |
+
"cui": "C0032326", "icd10": "J93.9", "snomed": "36118008",
|
| 45 |
+
"label": "Pneumothorax", "category": "Pulmonary",
|
| 46 |
+
"severity_weight": 0.9
|
| 47 |
+
},
|
| 48 |
+
"pleural effusion": {
|
| 49 |
+
"cui": "C0032227", "icd10": "J90", "snomed": "60046008",
|
| 50 |
+
"label": "Pleural Effusion", "category": "Pulmonary",
|
| 51 |
+
"severity_weight": 0.7
|
| 52 |
+
},
|
| 53 |
+
"atelectasis": {
|
| 54 |
+
"cui": "C0004144", "icd10": "J98.11", "snomed": "46621007",
|
| 55 |
+
"label": "Atelectasis", "category": "Pulmonary",
|
| 56 |
+
"severity_weight": 0.6
|
| 57 |
+
},
|
| 58 |
+
"pulmonary edema": {
|
| 59 |
+
"cui": "C0034063", "icd10": "J81.0", "snomed": "19242006",
|
| 60 |
+
"label": "Pulmonary Edema", "category": "Pulmonary",
|
| 61 |
+
"severity_weight": 0.8
|
| 62 |
+
},
|
| 63 |
+
"emphysema": {
|
| 64 |
+
"cui": "C0034067", "icd10": "J43.9", "snomed": "87433001",
|
| 65 |
+
"label": "Pulmonary Emphysema", "category": "Pulmonary",
|
| 66 |
+
"severity_weight": 0.6
|
| 67 |
+
},
|
| 68 |
+
"opacity": {
|
| 69 |
+
"cui": "C0333053", "icd10": "R91.8", "snomed": "408721000",
|
| 70 |
+
"label": "Pulmonary Opacity", "category": "Pulmonary",
|
| 71 |
+
"severity_weight": 0.5
|
| 72 |
+
},
|
| 73 |
+
"nodule": {
|
| 74 |
+
"cui": "C0034079", "icd10": "R91.1", "snomed": "27925004",
|
| 75 |
+
"label": "Pulmonary Nodule", "category": "Pulmonary",
|
| 76 |
+
"severity_weight": 0.6
|
| 77 |
+
},
|
| 78 |
+
"mass": {
|
| 79 |
+
"cui": "C0577559", "icd10": "R91.8", "snomed": "300848003",
|
| 80 |
+
"label": "Pulmonary Mass", "category": "Pulmonary",
|
| 81 |
+
"severity_weight": 0.8
|
| 82 |
+
},
|
| 83 |
+
"fibrosis": {
|
| 84 |
+
"cui": "C1800706", "icd10": "J84.10", "snomed": "40122008",
|
| 85 |
+
"label": "Pulmonary Fibrosis", "category": "Pulmonary",
|
| 86 |
+
"severity_weight": 0.7
|
| 87 |
+
},
|
| 88 |
+
|
| 89 |
+
# ── Cardiac ───────────────────────────────────────────────────────────
|
| 90 |
+
"cardiomegaly": {
|
| 91 |
+
"cui": "C0018800", "icd10": "I51.7", "snomed": "8186001",
|
| 92 |
+
"label": "Cardiomegaly", "category": "Cardiac",
|
| 93 |
+
"severity_weight": 0.7
|
| 94 |
+
},
|
| 95 |
+
"pericardial effusion": {
|
| 96 |
+
"cui": "C0031039", "icd10": "I30.9", "snomed": "373945007",
|
| 97 |
+
"label": "Pericardial Effusion", "category": "Cardiac",
|
| 98 |
+
"severity_weight": 0.8
|
| 99 |
+
},
|
| 100 |
+
|
| 101 |
+
# ── Neurological ──────────────────────────────────────────────────────
|
| 102 |
+
"hemorrhage": {
|
| 103 |
+
"cui": "C0019080", "icd10": "I62.9", "snomed": "50960005",
|
| 104 |
+
"label": "Intracranial Hemorrhage", "category": "Neurological",
|
| 105 |
+
"severity_weight": 0.95
|
| 106 |
+
},
|
| 107 |
+
"infarct": {
|
| 108 |
+
"cui": "C0007785", "icd10": "I63.9", "snomed": "432504007",
|
| 109 |
+
"label": "Cerebral Infarction", "category": "Neurological",
|
| 110 |
+
"severity_weight": 0.9
|
| 111 |
+
},
|
| 112 |
+
"hydrocephalus": {
|
| 113 |
+
"cui": "C0020255", "icd10": "G91.9", "snomed": "230745008",
|
| 114 |
+
"label": "Hydrocephalus", "category": "Neurological",
|
| 115 |
+
"severity_weight": 0.85
|
| 116 |
+
},
|
| 117 |
+
"midline shift": {
|
| 118 |
+
"cui": "C0235050", "icd10": "G93.5", "snomed": "44695005",
|
| 119 |
+
"label": "Midline Shift", "category": "Neurological",
|
| 120 |
+
"severity_weight": 0.9
|
| 121 |
+
},
|
| 122 |
+
"edema": {
|
| 123 |
+
"cui": "C0006960", "icd10": "G93.6", "snomed": "2776000",
|
| 124 |
+
"label": "Cerebral Edema", "category": "Neurological",
|
| 125 |
+
"severity_weight": 0.85
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
# ── Abdominal ────────────────��────────────────────────────────────────
|
| 129 |
+
"hepatomegaly": {
|
| 130 |
+
"cui": "C0019209", "icd10": "R16.0", "snomed": "80515008",
|
| 131 |
+
"label": "Hepatomegaly", "category": "Abdominal",
|
| 132 |
+
"severity_weight": 0.6
|
| 133 |
+
},
|
| 134 |
+
"splenomegaly": {
|
| 135 |
+
"cui": "C0038002", "icd10": "R16.1", "snomed": "16294009",
|
| 136 |
+
"label": "Splenomegaly", "category": "Abdominal",
|
| 137 |
+
"severity_weight": 0.6
|
| 138 |
+
},
|
| 139 |
+
"ascites": {
|
| 140 |
+
"cui": "C0003962", "icd10": "R18.0", "snomed": "389026000",
|
| 141 |
+
"label": "Ascites", "category": "Abdominal",
|
| 142 |
+
"severity_weight": 0.7
|
| 143 |
+
},
|
| 144 |
+
"obstruction": {
|
| 145 |
+
"cui": "C0029895", "icd10": "K56.60", "snomed": "81060008",
|
| 146 |
+
"label": "Bowel Obstruction", "category": "Abdominal",
|
| 147 |
+
"severity_weight": 0.8
|
| 148 |
+
},
|
| 149 |
+
"calculi": {
|
| 150 |
+
"cui": "C0022650", "icd10": "N20.0", "snomed": "95570007",
|
| 151 |
+
"label": "Urolithiasis/Calculi", "category": "Abdominal",
|
| 152 |
+
"severity_weight": 0.5
|
| 153 |
+
},
|
| 154 |
+
"gallstone": {
|
| 155 |
+
"cui": "C0008350", "icd10": "K80.20", "snomed": "266474003",
|
| 156 |
+
"label": "Cholelithiasis", "category": "Abdominal",
|
| 157 |
+
"severity_weight": 0.5
|
| 158 |
+
},
|
| 159 |
+
|
| 160 |
+
# ── Musculoskeletal ───────────────────────────────────────────────────
|
| 161 |
+
"fracture": {
|
| 162 |
+
"cui": "C0016658", "icd10": "M84.40", "snomed": "125605004",
|
| 163 |
+
"label": "Bone Fracture", "category": "Musculoskeletal",
|
| 164 |
+
"severity_weight": 0.75
|
| 165 |
+
},
|
| 166 |
+
"osteoporosis": {
|
| 167 |
+
"cui": "C0029456", "icd10": "M81.0", "snomed": "64859006",
|
| 168 |
+
"label": "Osteoporosis", "category": "Musculoskeletal",
|
| 169 |
+
"severity_weight": 0.6
|
| 170 |
+
},
|
| 171 |
+
"osteopenia": {
|
| 172 |
+
"cui": "C0029453", "icd10": "M85.80", "snomed": "312894000",
|
| 173 |
+
"label": "Osteopenia", "category": "Musculoskeletal",
|
| 174 |
+
"severity_weight": 0.4
|
| 175 |
+
},
|
| 176 |
+
"dislocation": {
|
| 177 |
+
"cui": "C0012691", "icd10": "M24.30", "snomed": "57637002",
|
| 178 |
+
"label": "Joint Dislocation", "category": "Musculoskeletal",
|
| 179 |
+
"severity_weight": 0.7
|
| 180 |
+
},
|
| 181 |
+
"arthritis": {
|
| 182 |
+
"cui": "C0003864", "icd10": "M19.90", "snomed": "57676002",
|
| 183 |
+
"label": "Arthritis", "category": "Musculoskeletal",
|
| 184 |
+
"severity_weight": 0.5
|
| 185 |
+
},
|
| 186 |
+
"arthroplasty": {
|
| 187 |
+
"cui": "C0003893", "icd10": "Z96.641", "snomed": "179294005",
|
| 188 |
+
"label": "Hip Arthroplasty/Implant", "category": "Musculoskeletal",
|
| 189 |
+
"severity_weight": 0.2
|
| 190 |
+
},
|
| 191 |
+
"implant": {
|
| 192 |
+
"cui": "C0021102", "icd10": "Z96.9", "snomed": "40388003",
|
| 193 |
+
"label": "Orthopedic Implant", "category": "Musculoskeletal",
|
| 194 |
+
"severity_weight": 0.2
|
| 195 |
+
},
|
| 196 |
+
"lytic": {
|
| 197 |
+
"cui": "C0262587", "icd10": "M89.50", "snomed": "203522001",
|
| 198 |
+
"label": "Lytic Bone Lesion", "category": "Musculoskeletal",
|
| 199 |
+
"severity_weight": 0.8
|
| 200 |
+
},
|
| 201 |
+
|
| 202 |
+
# ── Normal findings ───────────────────────────────────────────────────
|
| 203 |
+
"no acute": {
|
| 204 |
+
"cui": "C0442737", "icd10": "Z00.00", "snomed": "17621005",
|
| 205 |
+
"label": "No Acute Finding", "category": "Normal",
|
| 206 |
+
"severity_weight": 0.0
|
| 207 |
+
},
|
| 208 |
+
"normal": {
|
| 209 |
+
"cui": "C0442737", "icd10": "Z00.00", "snomed": "17621005",
|
| 210 |
+
"label": "Normal Study", "category": "Normal",
|
| 211 |
+
"severity_weight": 0.0
|
| 212 |
+
},
|
| 213 |
+
"unremarkable": {
|
| 214 |
+
"cui": "C0442737", "icd10": "Z00.00", "snomed": "17621005",
|
| 215 |
+
"label": "Unremarkable", "category": "Normal",
|
| 216 |
+
"severity_weight": 0.0
|
| 217 |
+
},
|
| 218 |
+
# ── Breast / Mammography ──────────────────────────────────────────────
|
| 219 |
+
"microcalcification": {
|
| 220 |
+
"cui": "C0024496", "icd10": "N64.89", "snomed": "129769009",
|
| 221 |
+
"label": "Breast Microcalcification", "category": "Breast", "severity_weight": 0.75
|
| 222 |
+
},
|
| 223 |
+
"breast mass": {
|
| 224 |
+
"cui": "C0024103", "icd10": "N63.0", "snomed": "59614007",
|
| 225 |
+
"label": "Breast Mass", "category": "Breast", "severity_weight": 0.8
|
| 226 |
+
},
|
| 227 |
+
"ductal": {
|
| 228 |
+
"cui": "C0007103", "icd10": "C50.919", "snomed": "413448000",
|
| 229 |
+
"label": "Ductal Carcinoma", "category": "Breast", "severity_weight": 0.95
|
| 230 |
+
},
|
| 231 |
+
|
| 232 |
+
# ── Thyroid / Neck ────────────────────────────────────────────────────
|
| 233 |
+
"goiter": {
|
| 234 |
+
"cui": "C0018021", "icd10": "E04.9", "snomed": "3716002",
|
| 235 |
+
"label": "Goiter", "category": "Thyroid", "severity_weight": 0.5
|
| 236 |
+
},
|
| 237 |
+
"thyroid nodule": {
|
| 238 |
+
"cui": "C0040136", "icd10": "E04.1", "snomed": "237495005",
|
| 239 |
+
"label": "Thyroid Nodule", "category": "Thyroid", "severity_weight": 0.6
|
| 240 |
+
},
|
| 241 |
+
"lymphadenopathy": {
|
| 242 |
+
"cui": "C0497156", "icd10": "R59.9", "snomed": "111590001",
|
| 243 |
+
"label": "Lymphadenopathy", "category": "Lymphatic", "severity_weight": 0.65
|
| 244 |
+
},
|
| 245 |
+
|
| 246 |
+
# ── Vascular ──────────────────────────────────────────────────────────
|
| 247 |
+
"aneurysm": {
|
| 248 |
+
"cui": "C0002940", "icd10": "I72.9", "snomed": "432119003",
|
| 249 |
+
"label": "Aneurysm", "category": "Vascular", "severity_weight": 0.9
|
| 250 |
+
},
|
| 251 |
+
"stenosis": {
|
| 252 |
+
"cui": "C1261287", "icd10": "I77.1", "snomed": "415068001",
|
| 253 |
+
"label": "Arterial Stenosis", "category": "Vascular", "severity_weight": 0.8
|
| 254 |
+
},
|
| 255 |
+
"thrombosis": {
|
| 256 |
+
"cui": "C0087086", "icd10": "I82.90", "snomed": "396347007",
|
| 257 |
+
"label": "Thrombosis", "category": "Vascular", "severity_weight": 0.85
|
| 258 |
+
},
|
| 259 |
+
"embolism": {
|
| 260 |
+
"cui": "C0014079", "icd10": "I74.9", "snomed": "59282003",
|
| 261 |
+
"label": "Pulmonary Embolism", "category": "Vascular", "severity_weight": 0.9
|
| 262 |
+
},
|
| 263 |
+
|
| 264 |
+
# ── Liver / Hepatobiliary ─────────────────────────────────────────────
|
| 265 |
+
"cirrhosis": {
|
| 266 |
+
"cui": "C0023895", "icd10": "K74.60", "snomed": "19943007",
|
| 267 |
+
"label": "Liver Cirrhosis", "category": "Hepatobiliary", "severity_weight": 0.8
|
| 268 |
+
},
|
| 269 |
+
"steatosis": {
|
| 270 |
+
"cui": "C0015695", "icd10": "K76.0", "snomed": "197315008",
|
| 271 |
+
"label": "Hepatic Steatosis", "category": "Hepatobiliary", "severity_weight": 0.55
|
| 272 |
+
},
|
| 273 |
+
"cholecystitis": {
|
| 274 |
+
"cui": "C0008325", "icd10": "K81.9", "snomed": "76581006",
|
| 275 |
+
"label": "Cholecystitis", "category": "Hepatobiliary", "severity_weight": 0.7
|
| 276 |
+
},
|
| 277 |
+
|
| 278 |
+
# ── Renal ─────────────────────────────────────────────────────────────
|
| 279 |
+
"hydronephrosis": {
|
| 280 |
+
"cui": "C0020295", "icd10": "N13.30", "snomed": "232895005",
|
| 281 |
+
"label": "Hydronephrosis", "category": "Renal", "severity_weight": 0.7
|
| 282 |
+
},
|
| 283 |
+
"renal cyst": {
|
| 284 |
+
"cui": "C0085413", "icd10": "N28.1", "snomed": "79411009",
|
| 285 |
+
"label": "Renal Cyst", "category": "Renal", "severity_weight": 0.3
|
| 286 |
+
},
|
| 287 |
+
|
| 288 |
+
# ── Spinal ────────────────────────────────────────────────────────────
|
| 289 |
+
"disc herniation": {
|
| 290 |
+
"cui": "C0242362", "icd10": "M51.16", "snomed": "73589001",
|
| 291 |
+
"label": "Disc Herniation", "category": "Spinal", "severity_weight": 0.65
|
| 292 |
+
},
|
| 293 |
+
"spondylosis": {
|
| 294 |
+
"cui": "C0038614", "icd10": "M47.816", "snomed": "41114007",
|
| 295 |
+
"label": "Spondylosis", "category": "Spinal", "severity_weight": 0.5
|
| 296 |
+
},
|
| 297 |
+
"spinal stenosis": {
|
| 298 |
+
"cui": "C0158288", "icd10": "M48.06", "snomed": "18946005",
|
| 299 |
+
"label": "Spinal Stenosis", "category": "Spinal", "severity_weight": 0.65
|
| 300 |
+
},
|
| 301 |
+
"compression fracture": {
|
| 302 |
+
"cui": "C0423697", "icd10": "M48.50", "snomed": "263057000",
|
| 303 |
+
"label": "Vertebral Compression Fracture", "category": "Spinal", "severity_weight": 0.75
|
| 304 |
+
},
|
| 305 |
+
|
| 306 |
+
# ── Paediatric / Obstetric ────────────────────────────────────────────
|
| 307 |
+
"placenta previa": {
|
| 308 |
+
"cui": "C0032046", "icd10": "O44.00", "snomed": "60758002",
|
| 309 |
+
"label": "Placenta Previa", "category": "Obstetric", "severity_weight": 0.85
|
| 310 |
+
},
|
| 311 |
+
"oligohydramnios": {
|
| 312 |
+
"cui": "C0030289", "icd10": "O41.00", "snomed": "59566000",
|
| 313 |
+
"label": "Oligohydramnios", "category": "Obstetric", "severity_weight": 0.8
|
| 314 |
+
},
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
# Severity level mapping
|
| 318 |
+
SEVERITY_LEVELS = {
|
| 319 |
+
(0.0, 0.0): "Normal",
|
| 320 |
+
(0.01, 0.39): "Minimal",
|
| 321 |
+
(0.4, 0.59): "Mild",
|
| 322 |
+
(0.6, 0.74): "Moderate",
|
| 323 |
+
(0.75, 0.89): "Significant",
|
| 324 |
+
(0.9, 1.0): "Critical",
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
# ---------------------------------------------------------------------------
|
| 329 |
+
# Data Models
|
| 330 |
+
# ---------------------------------------------------------------------------
|
| 331 |
+
|
| 332 |
+
@dataclass
|
| 333 |
+
class MedicalCode:
|
| 334 |
+
term: str
|
| 335 |
+
cui: str
|
| 336 |
+
icd10: str
|
| 337 |
+
snomed: str
|
| 338 |
+
label: str
|
| 339 |
+
category: str
|
| 340 |
+
severity_weight: float
|
| 341 |
+
context: str = "" # surrounding sentence
|
| 342 |
+
|
| 343 |
+
def to_dict(self) -> dict:
|
| 344 |
+
return {
|
| 345 |
+
"term_found": self.term,
|
| 346 |
+
"cui": self.cui,
|
| 347 |
+
"icd10": self.icd10,
|
| 348 |
+
"snomed": self.snomed,
|
| 349 |
+
"label": self.label,
|
| 350 |
+
"category": self.category,
|
| 351 |
+
"severity": self._severity_label(),
|
| 352 |
+
"context": self.context,
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
def _severity_label(self) -> str:
|
| 356 |
+
for (low, high), label in SEVERITY_LEVELS.items():
|
| 357 |
+
if low <= self.severity_weight <= high:
|
| 358 |
+
return label
|
| 359 |
+
return "Unknown"
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
@dataclass
|
| 363 |
+
class MedicalCodingResult:
|
| 364 |
+
codes: List[MedicalCode] = field(default_factory=list)
|
| 365 |
+
primary_diagnosis_cui: str = ""
|
| 366 |
+
primary_diagnosis_label: str = ""
|
| 367 |
+
icd10_codes: List[str] = field(default_factory=list)
|
| 368 |
+
snomed_codes: List[str] = field(default_factory=list)
|
| 369 |
+
categories_found: List[str] = field(default_factory=list)
|
| 370 |
+
overall_severity: str = "Normal"
|
| 371 |
+
coding_confidence: float = 0.0
|
| 372 |
+
raw_cuis: List[str] = field(default_factory=list)
|
| 373 |
+
|
| 374 |
+
def to_dict(self) -> dict:
|
| 375 |
+
return {
|
| 376 |
+
"medical_codes": [c.to_dict() for c in self.codes],
|
| 377 |
+
"primary_diagnosis": {
|
| 378 |
+
"cui": self.primary_diagnosis_cui,
|
| 379 |
+
"label": self.primary_diagnosis_label,
|
| 380 |
+
},
|
| 381 |
+
"icd10_codes": self.icd10_codes,
|
| 382 |
+
"snomed_codes": self.snomed_codes,
|
| 383 |
+
"categories_found": self.categories_found,
|
| 384 |
+
"overall_severity": self.overall_severity,
|
| 385 |
+
"coding_confidence": round(self.coding_confidence, 3),
|
| 386 |
+
"raw_cuis": self.raw_cuis,
|
| 387 |
+
"total_findings": len(self.codes),
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
def format_summary(self) -> str:
|
| 391 |
+
if not self.codes:
|
| 392 |
+
return "No significant medical concepts identified."
|
| 393 |
+
|
| 394 |
+
lines = [
|
| 395 |
+
f"**Primary Diagnosis:** {self.primary_diagnosis_label} "
|
| 396 |
+
f"[CUI: {self.primary_diagnosis_cui}]",
|
| 397 |
+
f"**Overall Severity:** {self.overall_severity}",
|
| 398 |
+
f"**Coding Confidence:** {self.coding_confidence:.0%}",
|
| 399 |
+
"",
|
| 400 |
+
"**Identified Medical Concepts:**",
|
| 401 |
+
]
|
| 402 |
+
for code in self.codes:
|
| 403 |
+
lines.append(
|
| 404 |
+
f" • {code.label} | "
|
| 405 |
+
f"CUI: `{code.cui}` | "
|
| 406 |
+
f"ICD-10: `{code.icd10}` | "
|
| 407 |
+
f"Severity: {code._severity_label()}"
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
if self.icd10_codes:
|
| 411 |
+
lines.append(f"\n**ICD-10 Codes:** {', '.join(self.icd10_codes)}")
|
| 412 |
+
if self.snomed_codes:
|
| 413 |
+
lines.append(f"**SNOMED CT:** {', '.join(self.snomed_codes)}")
|
| 414 |
+
|
| 415 |
+
return "\n".join(lines)
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
# ---------------------------------------------------------------------------
|
| 419 |
+
# Medical Coding Engine
|
| 420 |
+
# ---------------------------------------------------------------------------
|
| 421 |
+
|
| 422 |
+
class MedicalCodingEngine:
|
| 423 |
+
"""
|
| 424 |
+
Extracts medical concepts from report text and maps to
|
| 425 |
+
UMLS CUIs, ICD-10, and SNOMED CT codes.
|
| 426 |
+
"""
|
| 427 |
+
|
| 428 |
+
def extract_codes(self, report_text: str, sections: dict = None) -> MedicalCodingResult:
|
| 429 |
+
"""
|
| 430 |
+
Main entry point.
|
| 431 |
+
|
| 432 |
+
Args:
|
| 433 |
+
report_text : Full report text or findings/impression combined
|
| 434 |
+
sections : Optional dict with 'findings', 'impression' keys
|
| 435 |
+
|
| 436 |
+
Returns:
|
| 437 |
+
MedicalCodingResult
|
| 438 |
+
"""
|
| 439 |
+
# Combine findings + impression for analysis (most clinically relevant)
|
| 440 |
+
if sections:
|
| 441 |
+
analysis_text = (
|
| 442 |
+
sections.get("findings", "") + " " +
|
| 443 |
+
sections.get("impression", "") + " " +
|
| 444 |
+
sections.get("recommendations", "")
|
| 445 |
+
)
|
| 446 |
+
else:
|
| 447 |
+
analysis_text = report_text
|
| 448 |
+
|
| 449 |
+
analysis_text = analysis_text.lower()
|
| 450 |
+
|
| 451 |
+
# Extract matching concepts
|
| 452 |
+
found_codes: List[MedicalCode] = []
|
| 453 |
+
seen_cuis = set()
|
| 454 |
+
|
| 455 |
+
for term, concept in MEDICAL_CONCEPT_DB.items():
|
| 456 |
+
if term in analysis_text:
|
| 457 |
+
if concept["cui"] not in seen_cuis:
|
| 458 |
+
# Extract surrounding context
|
| 459 |
+
context = self._extract_context(analysis_text, term)
|
| 460 |
+
code = MedicalCode(
|
| 461 |
+
term=term,
|
| 462 |
+
cui=concept["cui"],
|
| 463 |
+
icd10=concept["icd10"],
|
| 464 |
+
snomed=concept["snomed"],
|
| 465 |
+
label=concept["label"],
|
| 466 |
+
category=concept["category"],
|
| 467 |
+
severity_weight=concept["severity_weight"],
|
| 468 |
+
context=context,
|
| 469 |
+
)
|
| 470 |
+
found_codes.append(code)
|
| 471 |
+
seen_cuis.add(concept["cui"])
|
| 472 |
+
|
| 473 |
+
# Sort by severity (most severe first)
|
| 474 |
+
found_codes.sort(key=lambda x: x.severity_weight, reverse=True)
|
| 475 |
+
|
| 476 |
+
# Build result
|
| 477 |
+
result = MedicalCodingResult()
|
| 478 |
+
result.codes = found_codes
|
| 479 |
+
result.raw_cuis = [c.cui for c in found_codes]
|
| 480 |
+
result.icd10_codes = list(dict.fromkeys(c.icd10 for c in found_codes))
|
| 481 |
+
result.snomed_codes = list(dict.fromkeys(c.snomed for c in found_codes))
|
| 482 |
+
result.categories_found = list(dict.fromkeys(c.category for c in found_codes))
|
| 483 |
+
|
| 484 |
+
# Primary diagnosis = highest severity non-normal finding
|
| 485 |
+
non_normal = [c for c in found_codes if c.category != "Normal"]
|
| 486 |
+
if non_normal:
|
| 487 |
+
primary = non_normal[0]
|
| 488 |
+
result.primary_diagnosis_cui = primary.cui
|
| 489 |
+
result.primary_diagnosis_label = primary.label
|
| 490 |
+
elif found_codes:
|
| 491 |
+
result.primary_diagnosis_cui = found_codes[0].cui
|
| 492 |
+
result.primary_diagnosis_label = found_codes[0].label
|
| 493 |
+
|
| 494 |
+
# Overall severity
|
| 495 |
+
if non_normal:
|
| 496 |
+
max_severity = max(c.severity_weight for c in non_normal)
|
| 497 |
+
for (low, high), label in SEVERITY_LEVELS.items():
|
| 498 |
+
if low <= max_severity <= high:
|
| 499 |
+
result.overall_severity = label
|
| 500 |
+
break
|
| 501 |
+
else:
|
| 502 |
+
result.overall_severity = "Normal"
|
| 503 |
+
|
| 504 |
+
# Coding confidence: based on number of matched terms
|
| 505 |
+
result.coding_confidence = min(len(found_codes) / 5.0, 1.0)
|
| 506 |
+
|
| 507 |
+
logger.info(
|
| 508 |
+
f"Medical coding: {len(found_codes)} concepts found | "
|
| 509 |
+
f"Primary: {result.primary_diagnosis_label} | "
|
| 510 |
+
f"Severity: {result.overall_severity}"
|
| 511 |
+
)
|
| 512 |
+
return result
|
| 513 |
+
|
| 514 |
+
def _extract_context(self, text: str, term: str, window: int = 80) -> str:
|
| 515 |
+
"""Extract surrounding sentence for context."""
|
| 516 |
+
idx = text.find(term)
|
| 517 |
+
if idx == -1:
|
| 518 |
+
return ""
|
| 519 |
+
start = max(0, idx - window)
|
| 520 |
+
end = min(len(text), idx + len(term) + window)
|
| 521 |
+
context = text[start:end].strip()
|
| 522 |
+
return f"...{context}..."
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
# ---------------------------------------------------------------------------
|
| 526 |
+
# Convenience function
|
| 527 |
+
# ---------------------------------------------------------------------------
|
| 528 |
+
|
| 529 |
+
def extract_medical_codes(report_text: str, sections: dict = None) -> MedicalCodingResult:
|
| 530 |
+
engine = MedicalCodingEngine()
|
| 531 |
+
return engine.extract_codes(report_text, sections)
|
model_loader.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
model_loader.py
|
| 3 |
+
---------------
|
| 4 |
+
Loads all AI models:
|
| 5 |
+
- LLM: epfl-llm/meditron-7b (4-bit NF4, ~5GB VRAM)
|
| 6 |
+
- Vision: Salesforce/blip2-opt-2.7b (float16, ~3.5GB VRAM)
|
| 7 |
+
|
| 8 |
+
VRAM budget on RTX 4060 8GB:
|
| 9 |
+
Meditron-7B 4-bit : ~5.0 GB
|
| 10 |
+
BLIP-2 : ~3.5 GB
|
| 11 |
+
NOTE: Run them sequentially (caption first, then LLM)
|
| 12 |
+
so both never sit in VRAM simultaneously.
|
| 13 |
+
Or use 8GB by loading BLIP-2, captioning, unloading,
|
| 14 |
+
then loading Meditron. This is handled automatically.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import gc
|
| 18 |
+
import logging
|
| 19 |
+
import os
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
SLM_MODEL_ID = os.getenv("SLM_MODEL_ID", "BioMistral/BioMistral-7B")
|
| 27 |
+
VISION_MODEL_ID = os.getenv("VISION_MODEL_ID", "Salesforce/blip2-opt-2.7b")
|
| 28 |
+
LORA_ADAPTER_PATH = os.getenv("LORA_ADAPTER_PATH", "models/slm_model/lora_adapter")
|
| 29 |
+
USE_4BIT = os.getenv("USE_4BIT", "true").lower() == "true"
|
| 30 |
+
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 31 |
+
|
| 32 |
+
logger.info(f"Device: {DEVICE} | 4-bit: {USE_4BIT}")
|
| 33 |
+
if DEVICE == "cuda":
|
| 34 |
+
logger.info(
|
| 35 |
+
f"GPU: {torch.cuda.get_device_name(0)} | "
|
| 36 |
+
f"VRAM: {torch.cuda.get_device_properties(0).total_memory/1e9:.1f}GB"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
_vision_model = None
|
| 40 |
+
_vision_processor = None
|
| 41 |
+
_slm_model = None
|
| 42 |
+
_slm_tokenizer = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 46 |
+
# VISION — BLIP-2 (generates real image captions)
|
| 47 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 48 |
+
|
| 49 |
+
def load_vision_encoder():
|
| 50 |
+
global _vision_model, _vision_processor
|
| 51 |
+
if _vision_model is not None:
|
| 52 |
+
return _vision_model, _vision_processor
|
| 53 |
+
|
| 54 |
+
logger.info(f"Loading BLIP-2: {VISION_MODEL_ID}")
|
| 55 |
+
from transformers import Blip2ForConditionalGeneration, Blip2Processor
|
| 56 |
+
|
| 57 |
+
_vision_processor = Blip2Processor.from_pretrained(
|
| 58 |
+
VISION_MODEL_ID, cache_dir="models/vision_encoder"
|
| 59 |
+
)
|
| 60 |
+
if DEVICE == "cuda":
|
| 61 |
+
_vision_model = Blip2ForConditionalGeneration.from_pretrained(
|
| 62 |
+
VISION_MODEL_ID,
|
| 63 |
+
torch_dtype=torch.float16,
|
| 64 |
+
device_map="auto",
|
| 65 |
+
cache_dir="models/vision_encoder",
|
| 66 |
+
)
|
| 67 |
+
else:
|
| 68 |
+
_vision_model = Blip2ForConditionalGeneration.from_pretrained(
|
| 69 |
+
VISION_MODEL_ID,
|
| 70 |
+
torch_dtype=torch.float32,
|
| 71 |
+
cache_dir="models/vision_encoder",
|
| 72 |
+
)
|
| 73 |
+
_vision_model.eval()
|
| 74 |
+
logger.info("BLIP-2 loaded.")
|
| 75 |
+
return _vision_model, _vision_processor
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def unload_vision_encoder():
|
| 79 |
+
"""Free VRAM after captioning so Meditron-7B can load."""
|
| 80 |
+
global _vision_model, _vision_processor
|
| 81 |
+
if _vision_model is not None:
|
| 82 |
+
del _vision_model
|
| 83 |
+
_vision_model = None
|
| 84 |
+
_vision_processor = None
|
| 85 |
+
if DEVICE == "cuda":
|
| 86 |
+
torch.cuda.empty_cache()
|
| 87 |
+
gc.collect()
|
| 88 |
+
logger.info("BLIP-2 unloaded — VRAM freed for Meditron.")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 92 |
+
# LLM — Meditron-7B (medical language model)
|
| 93 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 94 |
+
|
| 95 |
+
def load_slm():
|
| 96 |
+
global _slm_model, _slm_tokenizer
|
| 97 |
+
if _slm_model is not None:
|
| 98 |
+
return _slm_model, _slm_tokenizer
|
| 99 |
+
|
| 100 |
+
logger.info(f"Loading LLM: {SLM_MODEL_ID} | 4-bit: {USE_4BIT}")
|
| 101 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 102 |
+
|
| 103 |
+
_slm_tokenizer = AutoTokenizer.from_pretrained(
|
| 104 |
+
SLM_MODEL_ID,
|
| 105 |
+
trust_remote_code=True,
|
| 106 |
+
padding_side="left",
|
| 107 |
+
cache_dir="models/slm_model",
|
| 108 |
+
)
|
| 109 |
+
if _slm_tokenizer.pad_token is None:
|
| 110 |
+
_slm_tokenizer.pad_token = _slm_tokenizer.eos_token
|
| 111 |
+
|
| 112 |
+
if USE_4BIT and DEVICE == "cuda":
|
| 113 |
+
from transformers import BitsAndBytesConfig
|
| 114 |
+
bnb = BitsAndBytesConfig(
|
| 115 |
+
load_in_4bit=True,
|
| 116 |
+
bnb_4bit_use_double_quant=True,
|
| 117 |
+
bnb_4bit_quant_type="nf4",
|
| 118 |
+
bnb_4bit_compute_dtype=torch.bfloat16,
|
| 119 |
+
)
|
| 120 |
+
_slm_model = AutoModelForCausalLM.from_pretrained(
|
| 121 |
+
SLM_MODEL_ID,
|
| 122 |
+
quantization_config=bnb,
|
| 123 |
+
device_map="auto",
|
| 124 |
+
trust_remote_code=True,
|
| 125 |
+
cache_dir="models/slm_model",
|
| 126 |
+
)
|
| 127 |
+
logger.info("Meditron-7B loaded 4-bit NF4 (~5GB VRAM)")
|
| 128 |
+
elif DEVICE == "cuda":
|
| 129 |
+
_slm_model = AutoModelForCausalLM.from_pretrained(
|
| 130 |
+
SLM_MODEL_ID,
|
| 131 |
+
torch_dtype=torch.float16,
|
| 132 |
+
device_map="auto",
|
| 133 |
+
trust_remote_code=True,
|
| 134 |
+
cache_dir="models/slm_model",
|
| 135 |
+
)
|
| 136 |
+
logger.info("Meditron-7B loaded float16 (~14GB VRAM)")
|
| 137 |
+
else:
|
| 138 |
+
logger.warning("No GPU — CPU inference will be very slow")
|
| 139 |
+
_slm_model = AutoModelForCausalLM.from_pretrained(
|
| 140 |
+
SLM_MODEL_ID,
|
| 141 |
+
torch_dtype=torch.float32,
|
| 142 |
+
trust_remote_code=True,
|
| 143 |
+
low_cpu_mem_usage=True,
|
| 144 |
+
cache_dir="models/slm_model",
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
_slm_model = _apply_lora_adapter(_slm_model)
|
| 148 |
+
_slm_model.eval()
|
| 149 |
+
logger.info("Meditron-7B ready.")
|
| 150 |
+
return _slm_model, _slm_tokenizer
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _apply_lora_adapter(model):
|
| 154 |
+
adapter_path = Path(LORA_ADAPTER_PATH)
|
| 155 |
+
if not (adapter_path / "adapter_config.json").exists():
|
| 156 |
+
logger.info("No LoRA adapter — using base Meditron-7B.")
|
| 157 |
+
return model
|
| 158 |
+
try:
|
| 159 |
+
from peft import PeftModel
|
| 160 |
+
model = PeftModel.from_pretrained(model, str(adapter_path))
|
| 161 |
+
model = model.merge_and_unload()
|
| 162 |
+
logger.info(f"LoRA adapter loaded from {adapter_path}")
|
| 163 |
+
except Exception as e:
|
| 164 |
+
logger.warning(f"LoRA load failed: {e} — using base model.")
|
| 165 |
+
return model
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 169 |
+
# Image captioning — BLIP-2
|
| 170 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 171 |
+
|
| 172 |
+
def generate_image_caption(pil_image, vision_model=None,
|
| 173 |
+
vision_processor=None) -> str:
|
| 174 |
+
if vision_model is None or vision_processor is None:
|
| 175 |
+
vision_model, vision_processor = load_vision_encoder()
|
| 176 |
+
|
| 177 |
+
prompt = (
|
| 178 |
+
"Question: Describe this medical radiology image in detail. "
|
| 179 |
+
"Include all visible anatomy, any hardware or implants, "
|
| 180 |
+
"any pathology or abnormalities, and the image quality. Answer:"
|
| 181 |
+
)
|
| 182 |
+
try:
|
| 183 |
+
inputs = vision_processor(
|
| 184 |
+
images=pil_image, text=prompt, return_tensors="pt"
|
| 185 |
+
)
|
| 186 |
+
if DEVICE == "cuda":
|
| 187 |
+
inputs = {
|
| 188 |
+
k: v.to(DEVICE).to(torch.float16)
|
| 189 |
+
if v.dtype == torch.float32 else v.to(DEVICE)
|
| 190 |
+
for k, v in inputs.items()
|
| 191 |
+
}
|
| 192 |
+
with torch.no_grad():
|
| 193 |
+
ids = vision_model.generate(
|
| 194 |
+
**inputs,
|
| 195 |
+
max_new_tokens=150,
|
| 196 |
+
num_beams=4,
|
| 197 |
+
repetition_penalty=1.3,
|
| 198 |
+
)
|
| 199 |
+
caption = vision_processor.decode(ids[0], skip_special_tokens=True)
|
| 200 |
+
if "Answer:" in caption:
|
| 201 |
+
caption = caption.split("Answer:")[-1].strip()
|
| 202 |
+
logger.info(f"BLIP-2 caption: {caption[:150]}")
|
| 203 |
+
return caption
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.warning(f"BLIP-2 failed: {e}")
|
| 206 |
+
return ""
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def extract_visual_features(pil_image, vision_model=None,
|
| 210 |
+
vision_processor=None):
|
| 211 |
+
caption = generate_image_caption(pil_image, vision_model, vision_processor)
|
| 212 |
+
return None, caption
|
preprocessing.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
preprocessing.py
|
| 3 |
+
----------------
|
| 4 |
+
Full radiology image preprocessing pipeline.
|
| 5 |
+
Covers ALL modalities: X-ray, CT, MRI, Ultrasound, PET, Mammography,
|
| 6 |
+
Fluoroscopy, Nuclear Medicine, DEXA, Angiography.
|
| 7 |
+
Supports DICOM (.dcm), PNG, JPG.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import io
|
| 11 |
+
import logging
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
import cv2
|
| 16 |
+
import numpy as np
|
| 17 |
+
from PIL import Image
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class RadiologyPreprocessor:
|
| 23 |
+
TARGET_SIZE = (224, 224)
|
| 24 |
+
|
| 25 |
+
# ── Window presets for every modality + region combination ──────────
|
| 26 |
+
WINDOW_PRESETS = {
|
| 27 |
+
|
| 28 |
+
# X-RAY
|
| 29 |
+
"xray": {"wc": 40, "ww": 400},
|
| 30 |
+
"xray_chest": {"wc": -600, "ww": 1500},
|
| 31 |
+
"xray_abdomen": {"wc": 60, "ww": 400},
|
| 32 |
+
"xray_pelvis": {"wc": 40, "ww": 400},
|
| 33 |
+
"xray_spine": {"wc": 40, "ww": 400},
|
| 34 |
+
"xray_knee": {"wc": 40, "ww": 400},
|
| 35 |
+
"xray_shoulder": {"wc": 40, "ww": 400},
|
| 36 |
+
"xray_hand": {"wc": 40, "ww": 350},
|
| 37 |
+
"xray_foot": {"wc": 40, "ww": 350},
|
| 38 |
+
"xray_wrist": {"wc": 40, "ww": 350},
|
| 39 |
+
"xray_ankle": {"wc": 40, "ww": 350},
|
| 40 |
+
"xray_hip": {"wc": 40, "ww": 400},
|
| 41 |
+
"xray_skull": {"wc": 35, "ww": 80},
|
| 42 |
+
"xray_neck": {"wc": 40, "ww": 300},
|
| 43 |
+
|
| 44 |
+
# CT
|
| 45 |
+
"ct": {"wc": 40, "ww": 400},
|
| 46 |
+
"ct_chest": {"wc": -600, "ww": 1500},
|
| 47 |
+
"ct_abdomen": {"wc": 50, "ww": 350},
|
| 48 |
+
"ct_pelvis": {"wc": 50, "ww": 350},
|
| 49 |
+
"ct_brain": {"wc": 35, "ww": 80},
|
| 50 |
+
"ct_brain_bone": {"wc": 400, "ww": 2000},
|
| 51 |
+
"ct_brain_subdural": {"wc": 75, "ww": 215},
|
| 52 |
+
"ct_spine": {"wc": 400, "ww": 1800},
|
| 53 |
+
"ct_spine_soft": {"wc": 50, "ww": 350},
|
| 54 |
+
"ct_liver": {"wc": 60, "ww": 160},
|
| 55 |
+
"ct_lung": {"wc": -600, "ww": 1500},
|
| 56 |
+
"ct_mediastinum": {"wc": 50, "ww": 350},
|
| 57 |
+
"ct_bone": {"wc": 400, "ww": 1800},
|
| 58 |
+
"ct_angiography": {"wc": 300, "ww": 600},
|
| 59 |
+
"ct_cardiac": {"wc": 50, "ww": 350},
|
| 60 |
+
"ct_neck": {"wc": 50, "ww": 350},
|
| 61 |
+
"ct_kidney": {"wc": 50, "ww": 350},
|
| 62 |
+
"ct_pancreas": {"wc": 50, "ww": 250},
|
| 63 |
+
"ct_adrenal": {"wc": 35, "ww": 150},
|
| 64 |
+
|
| 65 |
+
# MRI
|
| 66 |
+
"mri": {"wc": 128, "ww": 256},
|
| 67 |
+
"mri_brain": {"wc": 128, "ww": 256},
|
| 68 |
+
"mri_spine": {"wc": 128, "ww": 256},
|
| 69 |
+
"mri_knee": {"wc": 128, "ww": 256},
|
| 70 |
+
"mri_shoulder": {"wc": 128, "ww": 256},
|
| 71 |
+
"mri_abdomen": {"wc": 128, "ww": 256},
|
| 72 |
+
"mri_pelvis": {"wc": 128, "ww": 256},
|
| 73 |
+
"mri_cardiac": {"wc": 128, "ww": 256},
|
| 74 |
+
"mri_breast": {"wc": 128, "ww": 256},
|
| 75 |
+
"mri_liver": {"wc": 128, "ww": 256},
|
| 76 |
+
"mri_prostate": {"wc": 128, "ww": 256},
|
| 77 |
+
"mri_neck": {"wc": 128, "ww": 256},
|
| 78 |
+
"mri_hip": {"wc": 128, "ww": 256},
|
| 79 |
+
"mri_ankle": {"wc": 128, "ww": 256},
|
| 80 |
+
"mri_wrist": {"wc": 128, "ww": 256},
|
| 81 |
+
"mri_fetal": {"wc": 128, "ww": 256},
|
| 82 |
+
|
| 83 |
+
# ULTRASOUND
|
| 84 |
+
"ultrasound": {"wc": 128, "ww": 256},
|
| 85 |
+
"ultrasound_abdomen": {"wc": 128, "ww": 256},
|
| 86 |
+
"ultrasound_pelvis": {"wc": 128, "ww": 256},
|
| 87 |
+
"ultrasound_thyroid": {"wc": 128, "ww": 256},
|
| 88 |
+
"ultrasound_breast": {"wc": 128, "ww": 256},
|
| 89 |
+
"ultrasound_cardiac": {"wc": 128, "ww": 256},
|
| 90 |
+
"ultrasound_obstetric": {"wc": 128, "ww": 256},
|
| 91 |
+
"ultrasound_vascular": {"wc": 128, "ww": 256},
|
| 92 |
+
"ultrasound_neck": {"wc": 128, "ww": 256},
|
| 93 |
+
"ultrasound_liver": {"wc": 128, "ww": 256},
|
| 94 |
+
"ultrasound_kidney": {"wc": 128, "ww": 256},
|
| 95 |
+
"ultrasound_testicular": {"wc": 128, "ww": 256},
|
| 96 |
+
"ultrasound_neonatal": {"wc": 128, "ww": 256},
|
| 97 |
+
|
| 98 |
+
# PET / NUCLEAR MEDICINE
|
| 99 |
+
"pet": {"wc": 128, "ww": 256},
|
| 100 |
+
"pet_brain": {"wc": 128, "ww": 256},
|
| 101 |
+
"pet_body": {"wc": 128, "ww": 256},
|
| 102 |
+
"nuclear medicine": {"wc": 128, "ww": 256},
|
| 103 |
+
"spect": {"wc": 128, "ww": 256},
|
| 104 |
+
|
| 105 |
+
# MAMMOGRAPHY
|
| 106 |
+
"mammography": {"wc": 1500, "ww": 6000},
|
| 107 |
+
"mammography_breast": {"wc": 1500, "ww": 6000},
|
| 108 |
+
|
| 109 |
+
# FLUOROSCOPY
|
| 110 |
+
"fluoroscopy": {"wc": 40, "ww": 400},
|
| 111 |
+
"fluoroscopy_gi": {"wc": 40, "ww": 400},
|
| 112 |
+
|
| 113 |
+
# ANGIOGRAPHY
|
| 114 |
+
"angiography": {"wc": 300, "ww": 600},
|
| 115 |
+
"angiography_cardiac": {"wc": 300, "ww": 600},
|
| 116 |
+
"angiography_cerebral": {"wc": 300, "ww": 600},
|
| 117 |
+
|
| 118 |
+
# DEXA (Bone Density)
|
| 119 |
+
"dexa": {"wc": 128, "ww": 256},
|
| 120 |
+
|
| 121 |
+
# Default fallback
|
| 122 |
+
"default": {"wc": 127, "ww": 255},
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
# ── Modality-specific enhancement settings ───────────────────────────
|
| 126 |
+
ENHANCEMENT_SETTINGS = {
|
| 127 |
+
"xray": {"clahe": True, "clahe_clip": 3.0, "denoise": False},
|
| 128 |
+
"ct": {"clahe": False, "clahe_clip": 2.0, "denoise": False},
|
| 129 |
+
"mri": {"clahe": True, "clahe_clip": 2.0, "denoise": True},
|
| 130 |
+
"ultrasound": {"clahe": True, "clahe_clip": 4.0, "denoise": True},
|
| 131 |
+
"pet": {"clahe": False, "clahe_clip": 2.0, "denoise": False},
|
| 132 |
+
"mammography": {"clahe": True, "clahe_clip": 2.5, "denoise": True},
|
| 133 |
+
"fluoroscopy": {"clahe": True, "clahe_clip": 3.0, "denoise": False},
|
| 134 |
+
"angiography": {"clahe": True, "clahe_clip": 2.0, "denoise": False},
|
| 135 |
+
"dexa": {"clahe": False, "clahe_clip": 2.0, "denoise": False},
|
| 136 |
+
"default": {"clahe": True, "clahe_clip": 2.0, "denoise": False},
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
def __init__(self, modality: str = "xray", body_region: str = "chest"):
|
| 140 |
+
self.modality = modality.lower().replace("-", " ").replace("_", " ")
|
| 141 |
+
self.body_region = body_region.lower().replace("-", " ")
|
| 142 |
+
self._window_preset = self._select_window_preset()
|
| 143 |
+
self._enhancement = self._select_enhancement()
|
| 144 |
+
|
| 145 |
+
# ------------------------------------------------------------------
|
| 146 |
+
# Public API
|
| 147 |
+
# ------------------------------------------------------------------
|
| 148 |
+
|
| 149 |
+
def preprocess(self, file_bytes: bytes, filename: str) -> np.ndarray:
|
| 150 |
+
"""Returns float32 numpy array (3, 224, 224) normalised to [0, 1]."""
|
| 151 |
+
ext = Path(filename).suffix.lower()
|
| 152 |
+
if ext == ".dcm":
|
| 153 |
+
image = self._load_dicom(file_bytes)
|
| 154 |
+
elif ext in {".png", ".jpg", ".jpeg"}:
|
| 155 |
+
image = self._load_standard_image(file_bytes)
|
| 156 |
+
else:
|
| 157 |
+
raise ValueError(f"Unsupported format: {ext}. Use .dcm, .png, or .jpg")
|
| 158 |
+
|
| 159 |
+
image = self._apply_modality_normalisation(image)
|
| 160 |
+
image = self._apply_enhancement(image)
|
| 161 |
+
image = self._resize(image)
|
| 162 |
+
return self._to_tensor(image)
|
| 163 |
+
|
| 164 |
+
def preprocess_pil(self, file_bytes: bytes, filename: str) -> Image.Image:
|
| 165 |
+
"""Returns a PIL Image for model input."""
|
| 166 |
+
tensor = self.preprocess(file_bytes, filename)
|
| 167 |
+
arr = (tensor.transpose(1, 2, 0) * 255).astype(np.uint8)
|
| 168 |
+
return Image.fromarray(arr)
|
| 169 |
+
|
| 170 |
+
# ------------------------------------------------------------------
|
| 171 |
+
# Private
|
| 172 |
+
# ------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
def _select_window_preset(self) -> dict:
|
| 175 |
+
# Try modality_region key first
|
| 176 |
+
mod_key = self.modality.replace(" ", "_")
|
| 177 |
+
reg_key = self.body_region.replace(" ", "_")
|
| 178 |
+
combined = f"{mod_key}_{reg_key}"
|
| 179 |
+
|
| 180 |
+
if combined in self.WINDOW_PRESETS:
|
| 181 |
+
return self.WINDOW_PRESETS[combined]
|
| 182 |
+
if mod_key in self.WINDOW_PRESETS:
|
| 183 |
+
return self.WINDOW_PRESETS[mod_key]
|
| 184 |
+
return self.WINDOW_PRESETS["default"]
|
| 185 |
+
|
| 186 |
+
def _select_enhancement(self) -> dict:
|
| 187 |
+
mod_key = self.modality.replace(" ", "_").split("_")[0]
|
| 188 |
+
return self.ENHANCEMENT_SETTINGS.get(
|
| 189 |
+
mod_key, self.ENHANCEMENT_SETTINGS["default"]
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
def _load_dicom(self, file_bytes: bytes) -> np.ndarray:
|
| 193 |
+
try:
|
| 194 |
+
import pydicom
|
| 195 |
+
except ImportError:
|
| 196 |
+
raise RuntimeError("Install pydicom: pip install pydicom")
|
| 197 |
+
|
| 198 |
+
ds = pydicom.dcmread(io.BytesIO(file_bytes))
|
| 199 |
+
pixel_array = ds.pixel_array.astype(np.float32)
|
| 200 |
+
|
| 201 |
+
slope = float(getattr(ds, "RescaleSlope", 1))
|
| 202 |
+
intercept = float(getattr(ds, "RescaleIntercept", 0))
|
| 203 |
+
pixel_array = pixel_array * slope + intercept
|
| 204 |
+
|
| 205 |
+
# Try DICOM tags first, fall back to preset
|
| 206 |
+
wc = float(getattr(ds, "WindowCenter",
|
| 207 |
+
self._window_preset["wc"]))
|
| 208 |
+
ww = float(getattr(ds, "WindowWidth",
|
| 209 |
+
self._window_preset["ww"]))
|
| 210 |
+
|
| 211 |
+
try:
|
| 212 |
+
import pydicom.multival
|
| 213 |
+
if isinstance(wc, pydicom.multival.MultiValue):
|
| 214 |
+
wc = float(wc[0])
|
| 215 |
+
if isinstance(ww, pydicom.multival.MultiValue):
|
| 216 |
+
ww = float(ww[0])
|
| 217 |
+
except Exception:
|
| 218 |
+
pass
|
| 219 |
+
|
| 220 |
+
return self._apply_windowing(pixel_array, wc, ww)
|
| 221 |
+
|
| 222 |
+
def _load_standard_image(self, file_bytes: bytes) -> np.ndarray:
|
| 223 |
+
arr = np.frombuffer(file_bytes, dtype=np.uint8)
|
| 224 |
+
img = cv2.imdecode(arr, cv2.IMREAD_GRAYSCALE)
|
| 225 |
+
if img is None:
|
| 226 |
+
raise ValueError("Could not decode image.")
|
| 227 |
+
return img.astype(np.float32)
|
| 228 |
+
|
| 229 |
+
@staticmethod
|
| 230 |
+
def _apply_windowing(pixel_array, wc, ww) -> np.ndarray:
|
| 231 |
+
lower = wc - ww / 2
|
| 232 |
+
upper = wc + ww / 2
|
| 233 |
+
windowed = np.clip(pixel_array, lower, upper)
|
| 234 |
+
windowed = (windowed - lower) / (ww + 1e-8)
|
| 235 |
+
return (windowed * 255).astype(np.float32)
|
| 236 |
+
|
| 237 |
+
def _apply_modality_normalisation(self, image: np.ndarray) -> np.ndarray:
|
| 238 |
+
p2, p98 = np.percentile(image, [2, 98])
|
| 239 |
+
image = np.clip(image, p2, p98)
|
| 240 |
+
image = (image - p2) / (p98 - p2 + 1e-8) * 255.0
|
| 241 |
+
return image.astype(np.float32)
|
| 242 |
+
|
| 243 |
+
def _apply_enhancement(self, image: np.ndarray) -> np.ndarray:
|
| 244 |
+
"""Apply CLAHE and/or denoising based on modality."""
|
| 245 |
+
img_uint8 = np.clip(image, 0, 255).astype(np.uint8)
|
| 246 |
+
|
| 247 |
+
if self._enhancement.get("denoise"):
|
| 248 |
+
img_uint8 = cv2.fastNlMeansDenoising(img_uint8, h=10)
|
| 249 |
+
|
| 250 |
+
if self._enhancement.get("clahe"):
|
| 251 |
+
clip = self._enhancement.get("clahe_clip", 2.0)
|
| 252 |
+
clahe = cv2.createCLAHE(clipLimit=clip, tileGridSize=(8, 8))
|
| 253 |
+
img_uint8 = clahe.apply(img_uint8)
|
| 254 |
+
|
| 255 |
+
return img_uint8.astype(np.float32)
|
| 256 |
+
|
| 257 |
+
def _resize(self, image: np.ndarray) -> np.ndarray:
|
| 258 |
+
h, w = self.TARGET_SIZE
|
| 259 |
+
return cv2.resize(image, (w, h), interpolation=cv2.INTER_LANCZOS4)
|
| 260 |
+
|
| 261 |
+
def _to_tensor(self, image: np.ndarray) -> np.ndarray:
|
| 262 |
+
normalised = image / 255.0
|
| 263 |
+
return np.stack([normalised, normalised, normalised], axis=0).astype(np.float32)
|
report_generator.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
report_generator.py v6 — BioMistral-7B powered
|
| 3 |
+
------------------------------------------------
|
| 4 |
+
Complete file with all methods intact.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import re
|
| 9 |
+
import time
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class PatientInfo:
|
| 19 |
+
age: int
|
| 20 |
+
gender: str
|
| 21 |
+
symptoms: str
|
| 22 |
+
clinical_history: str
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class ImagingInfo:
|
| 27 |
+
modality: str
|
| 28 |
+
body_region: str
|
| 29 |
+
view: str = ""
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class RadiologyReport:
|
| 34 |
+
examination_details: str = ""
|
| 35 |
+
clinical_information: str = ""
|
| 36 |
+
findings: str = ""
|
| 37 |
+
impression: str = ""
|
| 38 |
+
recommendations: str = ""
|
| 39 |
+
confidence_score: float = 0.0
|
| 40 |
+
generation_time_seconds: float = 0.0
|
| 41 |
+
raw_text: str = ""
|
| 42 |
+
image_caption: str = ""
|
| 43 |
+
|
| 44 |
+
def to_dict(self) -> dict:
|
| 45 |
+
return {
|
| 46 |
+
"examination_details": self.examination_details,
|
| 47 |
+
"clinical_information": self.clinical_information,
|
| 48 |
+
"findings": self.findings,
|
| 49 |
+
"impression": self.impression,
|
| 50 |
+
"recommendations": self.recommendations,
|
| 51 |
+
"confidence_score": self.confidence_score,
|
| 52 |
+
"generation_time_seconds": self.generation_time_seconds,
|
| 53 |
+
"image_caption": self.image_caption,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
def to_text(self) -> str:
|
| 57 |
+
return "\n".join([
|
| 58 |
+
"RADIOLOGY REPORT",
|
| 59 |
+
"=" * 60,
|
| 60 |
+
"",
|
| 61 |
+
"EXAMINATION DETAILS:",
|
| 62 |
+
self.examination_details,
|
| 63 |
+
"",
|
| 64 |
+
"CLINICAL INFORMATION:",
|
| 65 |
+
self.clinical_information,
|
| 66 |
+
"",
|
| 67 |
+
"FINDINGS:",
|
| 68 |
+
self.findings,
|
| 69 |
+
"",
|
| 70 |
+
"IMPRESSION:",
|
| 71 |
+
self.impression,
|
| 72 |
+
"",
|
| 73 |
+
"RECOMMENDATIONS:",
|
| 74 |
+
self.recommendations,
|
| 75 |
+
"",
|
| 76 |
+
f"CONFIDENCE SCORE: {self.confidence_score:.2f}",
|
| 77 |
+
"",
|
| 78 |
+
"=" * 60,
|
| 79 |
+
"END OF REPORT",
|
| 80 |
+
])
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
REGION_FOCUS = {
|
| 84 |
+
"chest": "lung fields, cardiac silhouette, mediastinum, pleural spaces, bony thorax",
|
| 85 |
+
"brain": "brain parenchyma, ventricles, sulci, white matter, midline structures, posterior fossa",
|
| 86 |
+
"abdomen": "liver, spleen, kidneys, pancreas, bowel loops, peritoneal spaces",
|
| 87 |
+
"pelvis": "pelvic bones, hip joints, bladder, iliac crests, femoral heads, any fixation hardware",
|
| 88 |
+
"spine": "vertebral bodies, disc spaces, spinal canal, neural foramina, alignment",
|
| 89 |
+
"knee": "femoral condyles, tibial plateau, patella, joint space, menisci",
|
| 90 |
+
"shoulder": "glenohumeral joint, rotator cuff, humeral head, acromion",
|
| 91 |
+
"neck": "cervical spine, soft tissues, thyroid, lymph nodes",
|
| 92 |
+
"breast": "fibroglandular tissue, lesions, microcalcifications, axillary nodes",
|
| 93 |
+
"cardiac": "cardiac chambers, great vessels, pericardium",
|
| 94 |
+
"liver": "hepatic parenchyma, biliary system, portal veins",
|
| 95 |
+
"thyroid": "thyroid lobes, echogenicity, nodules, vascularity",
|
| 96 |
+
"obstetric": "fetal anatomy, placenta, amniotic fluid, cervix",
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def build_meditron_prompt(patient: PatientInfo, imaging: ImagingInfo,
|
| 101 |
+
image_caption: str) -> str:
|
| 102 |
+
view_str = f", {imaging.view} view" if imaging.view else ""
|
| 103 |
+
focus = REGION_FOCUS.get(imaging.body_region.lower(),
|
| 104 |
+
f"{imaging.body_region} anatomy and pathology")
|
| 105 |
+
vision_line = ""
|
| 106 |
+
if image_caption and len(image_caption) > 20:
|
| 107 |
+
vision_line = f"\nIMAGE DESCRIPTION: {image_caption}"
|
| 108 |
+
|
| 109 |
+
prompt = (
|
| 110 |
+
f"### Instruction:\n"
|
| 111 |
+
f"You are a consultant radiologist. Write a structured radiology report "
|
| 112 |
+
f"using standard clinical terminology. Be concise and specific. "
|
| 113 |
+
f"Only report what is visible. Do not speculate beyond the findings.\n\n"
|
| 114 |
+
f"STUDY: {imaging.modality} {imaging.body_region}{view_str}\n"
|
| 115 |
+
f"PATIENT: {patient.age}-year-old {patient.gender}\n"
|
| 116 |
+
f"CLINICAL INDICATION: {patient.symptoms}\n"
|
| 117 |
+
f"HISTORY: {patient.clinical_history}\n"
|
| 118 |
+
f"KEY STRUCTURES: {focus}{vision_line}\n\n"
|
| 119 |
+
f"Write exactly three sections:\n"
|
| 120 |
+
f"FINDINGS: (2-4 sentences describing what is seen)\n"
|
| 121 |
+
f"IMPRESSION: (1-2 sentences — the diagnosis or conclusion)\n"
|
| 122 |
+
f"RECOMMENDATIONS: (1-2 sentences — next clinical steps)\n\n"
|
| 123 |
+
f"### Response:\n"
|
| 124 |
+
f"FINDINGS: The {imaging.body_region} demonstrates"
|
| 125 |
+
)
|
| 126 |
+
return prompt
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class ReportGenerator:
|
| 130 |
+
MAX_NEW_TOKENS = 300
|
| 131 |
+
TEMPERATURE = 0.1
|
| 132 |
+
TOP_P = 0.85
|
| 133 |
+
REPETITION_PENALTY = 1.2
|
| 134 |
+
|
| 135 |
+
def __init__(self, slm_model=None, slm_tokenizer=None):
|
| 136 |
+
self.model = slm_model
|
| 137 |
+
self.tokenizer = slm_tokenizer
|
| 138 |
+
self._device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 139 |
+
|
| 140 |
+
def generate(self, patient: PatientInfo, imaging: ImagingInfo,
|
| 141 |
+
image_caption: str = "", visual_features=None) -> RadiologyReport:
|
| 142 |
+
prompt = build_meditron_prompt(patient, imaging, image_caption)
|
| 143 |
+
start = time.time()
|
| 144 |
+
raw = self._run_inference(prompt)
|
| 145 |
+
elapsed = time.time() - start
|
| 146 |
+
logger.info(f"Raw (250c): {raw[:250]}")
|
| 147 |
+
report = self._parse(raw, patient, imaging)
|
| 148 |
+
self._validate_consistency(report, imaging)
|
| 149 |
+
report.raw_text = raw
|
| 150 |
+
report.image_caption = image_caption
|
| 151 |
+
report.generation_time_seconds = round(elapsed, 2)
|
| 152 |
+
report.confidence_score = self._score(report, imaging)
|
| 153 |
+
return report
|
| 154 |
+
|
| 155 |
+
# ─────────────────────────────────────────────────────────────────
|
| 156 |
+
# Inference
|
| 157 |
+
# ─────────────────────────────────────────────────────────────────
|
| 158 |
+
|
| 159 |
+
def _run_inference(self, prompt: str) -> str:
|
| 160 |
+
inputs = self.tokenizer(
|
| 161 |
+
prompt,
|
| 162 |
+
return_tensors="pt",
|
| 163 |
+
truncation=True,
|
| 164 |
+
max_length=800,
|
| 165 |
+
padding=False,
|
| 166 |
+
).to(self._device)
|
| 167 |
+
|
| 168 |
+
with torch.no_grad():
|
| 169 |
+
out = self.model.generate(
|
| 170 |
+
**inputs,
|
| 171 |
+
max_new_tokens=self.MAX_NEW_TOKENS,
|
| 172 |
+
temperature=self.TEMPERATURE,
|
| 173 |
+
top_p=self.TOP_P,
|
| 174 |
+
repetition_penalty=self.REPETITION_PENALTY,
|
| 175 |
+
do_sample=True,
|
| 176 |
+
pad_token_id=self.tokenizer.eos_token_id,
|
| 177 |
+
eos_token_id=self.tokenizer.eos_token_id,
|
| 178 |
+
)
|
| 179 |
+
new = out[0][inputs["input_ids"].shape[1]:]
|
| 180 |
+
return self.tokenizer.decode(new, skip_special_tokens=True)
|
| 181 |
+
|
| 182 |
+
# ─────────────────────────────────────────────────────────────────
|
| 183 |
+
# Text cleaning
|
| 184 |
+
# ─────────────────────────────────────────────────────────────────
|
| 185 |
+
|
| 186 |
+
def _clean(self, text: str) -> str:
|
| 187 |
+
text = re.sub(r"```[\s\S]*?```", " ", text)
|
| 188 |
+
text = re.sub(r"^###.*$", "", text, flags=re.MULTILINE)
|
| 189 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 190 |
+
text = re.sub(r"[ \t]{2,}", " ", text)
|
| 191 |
+
return text.strip()
|
| 192 |
+
|
| 193 |
+
def _cut_at_sentences(self, text: str, max_sent: int = 4) -> str:
|
| 194 |
+
sentences = re.split(r'(?<=[.!?])\s+', text.strip())
|
| 195 |
+
good = []
|
| 196 |
+
for s in sentences:
|
| 197 |
+
s = s.strip()
|
| 198 |
+
if len(s) < 8:
|
| 199 |
+
continue
|
| 200 |
+
good.append(s)
|
| 201 |
+
if len(good) >= max_sent:
|
| 202 |
+
break
|
| 203 |
+
return " ".join(good)
|
| 204 |
+
|
| 205 |
+
def _extract(self, text: str, section: str, max_sent: int = 3) -> str:
|
| 206 |
+
aliases = {
|
| 207 |
+
"findings": ["FINDINGS", "Findings"],
|
| 208 |
+
"impression": ["IMPRESSION", "Impression", "CONCLUSION",
|
| 209 |
+
"Conclusion", "DIAGNOSIS"],
|
| 210 |
+
"recommendations": ["RECOMMENDATIONS", "Recommendations",
|
| 211 |
+
"RECOMMENDATION", "PLAN", "Plan"],
|
| 212 |
+
}
|
| 213 |
+
all_hdrs = [h for v in aliases.values() for h in v]
|
| 214 |
+
headers = aliases.get(section, [section.upper()])
|
| 215 |
+
stopper = "|".join(re.escape(h) for h in all_hdrs if h not in headers)
|
| 216 |
+
|
| 217 |
+
for hdr in headers:
|
| 218 |
+
pat = (rf"(?:^|\n){re.escape(hdr)}\s*:?\s*(.*?)"
|
| 219 |
+
rf"(?=\n(?:{stopper})\s*:?|\Z)")
|
| 220 |
+
m = re.search(pat, text, re.DOTALL | re.IGNORECASE)
|
| 221 |
+
if m:
|
| 222 |
+
val = self._clean(m.group(1))
|
| 223 |
+
val = self._cut_at_sentences(val, max_sent)
|
| 224 |
+
val = re.sub(r"\s{2,}", " ", val).strip("- \n")
|
| 225 |
+
if len(val) > 15:
|
| 226 |
+
return val
|
| 227 |
+
return ""
|
| 228 |
+
|
| 229 |
+
# ─────────────────────────────────────────────────────────────────
|
| 230 |
+
# Parsing
|
| 231 |
+
# ─────────────────────────────────────────────────────────────────
|
| 232 |
+
|
| 233 |
+
def _parse(self, raw: str, patient: PatientInfo,
|
| 234 |
+
imaging: ImagingInfo) -> RadiologyReport:
|
| 235 |
+
report = RadiologyReport()
|
| 236 |
+
view_str = f", {imaging.view} view" if imaging.view else ""
|
| 237 |
+
|
| 238 |
+
report.examination_details = (
|
| 239 |
+
f"{imaging.modality} examination of the {imaging.body_region}"
|
| 240 |
+
f"{view_str} was performed."
|
| 241 |
+
)
|
| 242 |
+
# Fix double periods (e.g. "infection.." -> "infection.")
|
| 243 |
+
symptoms_clean = patient.symptoms.rstrip(". ")
|
| 244 |
+
history_clean = patient.clinical_history.rstrip(". ")
|
| 245 |
+
report.clinical_information = (
|
| 246 |
+
f"{patient.age}-year-old {patient.gender} presenting with "
|
| 247 |
+
f"{symptoms_clean}. History: {history_clean}."
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
full = self._clean(
|
| 251 |
+
f"FINDINGS: The {imaging.body_region} demonstrates " + raw
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
report.findings = self._extract(full, "findings", 3)
|
| 255 |
+
report.impression = self._extract(full, "impression", 2)
|
| 256 |
+
report.recommendations = self._extract(full, "recommendations", 2)
|
| 257 |
+
|
| 258 |
+
# Findings fallback
|
| 259 |
+
if len(report.findings) < 15:
|
| 260 |
+
lines = [l.strip() for l in full.split("\n") if len(l.strip()) > 30]
|
| 261 |
+
if lines:
|
| 262 |
+
candidate = re.sub(r"^FINDINGS\s*:\s*", "", lines[0], flags=re.I)
|
| 263 |
+
report.findings = self._cut_at_sentences(candidate, 3)
|
| 264 |
+
|
| 265 |
+
# Impression fallback
|
| 266 |
+
if len(report.impression) < 15:
|
| 267 |
+
f_lower = report.findings.lower()
|
| 268 |
+
abnormal_keywords = [
|
| 269 |
+
"opacity", "opacities", "ground glass", "consolidation",
|
| 270 |
+
"lesion", "mass", "nodule", "effusion", "infiltrate",
|
| 271 |
+
"atelectasis", "fibrosis", "infection",
|
| 272 |
+
]
|
| 273 |
+
normal_keywords = [
|
| 274 |
+
"no acute", "no evidence", "unremarkable",
|
| 275 |
+
"within normal", "normal", "intact",
|
| 276 |
+
]
|
| 277 |
+
if any(w in f_lower for w in abnormal_keywords):
|
| 278 |
+
report.impression = (
|
| 279 |
+
f"Abnormal findings in the {imaging.body_region.lower()} as described. "
|
| 280 |
+
f"Clinical correlation is recommended."
|
| 281 |
+
)
|
| 282 |
+
elif any(w in f_lower for w in normal_keywords):
|
| 283 |
+
report.impression = (
|
| 284 |
+
f"No acute {imaging.body_region.lower()} abnormality identified. "
|
| 285 |
+
f"Clinical correlation is recommended."
|
| 286 |
+
)
|
| 287 |
+
else:
|
| 288 |
+
report.impression = (
|
| 289 |
+
f"Findings in the {imaging.body_region.lower()} as described. "
|
| 290 |
+
f"Clinical correlation is recommended."
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
# Recommendations fallback
|
| 294 |
+
if len(report.recommendations) < 15:
|
| 295 |
+
report.recommendations = (
|
| 296 |
+
"Clinical correlation with the patient's history and examination "
|
| 297 |
+
"findings is recommended. Follow-up imaging as clinically indicated."
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
return report
|
| 301 |
+
|
| 302 |
+
# ─────────────────────────────────────────────────────────────────
|
| 303 |
+
# Consistency check
|
| 304 |
+
# ─────────────────────────────────────────────────────────────────
|
| 305 |
+
|
| 306 |
+
def _validate_consistency(self, report: RadiologyReport,
|
| 307 |
+
imaging: ImagingInfo):
|
| 308 |
+
f = report.findings.lower()
|
| 309 |
+
imp = report.impression.lower()
|
| 310 |
+
abnormal_keywords = [
|
| 311 |
+
"opacity", "opacities", "ground glass", "consolidation",
|
| 312 |
+
"lesion", "mass", "nodule", "effusion", "infiltrate",
|
| 313 |
+
]
|
| 314 |
+
if any(w in f for w in abnormal_keywords):
|
| 315 |
+
if any(x in imp for x in ["no acute", "normal", "unremarkable"]):
|
| 316 |
+
report.impression = self._build_specific_impression(
|
| 317 |
+
report, imaging
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
def _build_specific_impression(self, report: RadiologyReport,
|
| 321 |
+
imaging: ImagingInfo) -> str:
|
| 322 |
+
"""
|
| 323 |
+
Builds a specific, clinically meaningful impression by:
|
| 324 |
+
1. Extracting the key finding from FINDINGS
|
| 325 |
+
2. Incorporating clinical history context
|
| 326 |
+
3. Suggesting a differential diagnosis
|
| 327 |
+
"""
|
| 328 |
+
f = report.findings.lower()
|
| 329 |
+
hist = report.clinical_information.lower()
|
| 330 |
+
region = imaging.body_region.lower()
|
| 331 |
+
|
| 332 |
+
# ── Identify key finding ───────────────────────────────────
|
| 333 |
+
if "ground glass" in f or "ggo" in f:
|
| 334 |
+
key_finding = "Ground-glass opacities"
|
| 335 |
+
if "bilateral" in f or "both lung" in f:
|
| 336 |
+
key_finding = "Bilateral ground-glass opacities"
|
| 337 |
+
elif "consolidation" in f:
|
| 338 |
+
key_finding = "Airspace consolidation"
|
| 339 |
+
if "bilateral" in f:
|
| 340 |
+
key_finding = "Bilateral consolidation"
|
| 341 |
+
elif "right" in f:
|
| 342 |
+
key_finding = "Right-sided consolidation"
|
| 343 |
+
elif "left" in f:
|
| 344 |
+
key_finding = "Left-sided consolidation"
|
| 345 |
+
elif "opacity" in f or "opacities" in f:
|
| 346 |
+
key_finding = "Pulmonary opacities"
|
| 347 |
+
if "bilateral" in f:
|
| 348 |
+
key_finding = "Bilateral pulmonary opacities"
|
| 349 |
+
elif "nodule" in f:
|
| 350 |
+
key_finding = "Pulmonary nodule(s)"
|
| 351 |
+
elif "effusion" in f:
|
| 352 |
+
key_finding = "Pleural effusion"
|
| 353 |
+
if "bilateral" in f:
|
| 354 |
+
key_finding = "Bilateral pleural effusions"
|
| 355 |
+
elif "mass" in f or "lesion" in f:
|
| 356 |
+
key_finding = f"Focal {region} lesion"
|
| 357 |
+
elif "infiltrate" in f:
|
| 358 |
+
key_finding = "Pulmonary infiltrates"
|
| 359 |
+
else:
|
| 360 |
+
key_finding = f"Abnormal {region} findings"
|
| 361 |
+
|
| 362 |
+
# ── Clinical context from history ──────────────────────────
|
| 363 |
+
context = ""
|
| 364 |
+
if any(w in hist for w in ["immunosuppress", "immunocompromis",
|
| 365 |
+
"mycophenol", "steroid", "transplant",
|
| 366 |
+
"hiv", "aids", "chemotherapy"]):
|
| 367 |
+
context = (
|
| 368 |
+
f" In the context of immunosuppression, opportunistic "
|
| 369 |
+
f"infection (e.g. PCP, fungal, CMV pneumonitis) should "
|
| 370 |
+
f"be considered."
|
| 371 |
+
)
|
| 372 |
+
elif any(w in hist for w in ["infection", "pneumonia", "fever", "cough"]):
|
| 373 |
+
context = (
|
| 374 |
+
f" Findings are consistent with an infective or "
|
| 375 |
+
f"inflammatory aetiology."
|
| 376 |
+
)
|
| 377 |
+
elif any(w in hist for w in ["malignancy", "cancer", "tumour",
|
| 378 |
+
"tumor", "oncology", "lymphoma"]):
|
| 379 |
+
context = (
|
| 380 |
+
f" In the context of known malignancy, metastatic "
|
| 381 |
+
f"disease should be considered."
|
| 382 |
+
)
|
| 383 |
+
elif any(w in hist for w in ["autoimmune", "lupus", "rheumatoid",
|
| 384 |
+
"sarcoid", "vasculitis"]):
|
| 385 |
+
context = (
|
| 386 |
+
f" In the context of autoimmune disease, an inflammatory "
|
| 387 |
+
f"or drug-related aetiology should be considered."
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
# ── Build impression ───────────────────────────────────────
|
| 391 |
+
impression = (
|
| 392 |
+
f"{key_finding} as described above, likely representing "
|
| 393 |
+
f"an infective or inflammatory process.{context} "
|
| 394 |
+
f"Clinical correlation is recommended."
|
| 395 |
+
)
|
| 396 |
+
return impression
|
| 397 |
+
|
| 398 |
+
# ─────────────────────────────────────────────────────────────────
|
| 399 |
+
# Confidence scoring
|
| 400 |
+
# ─────────────────────────────────────────────────────────────────
|
| 401 |
+
|
| 402 |
+
def _score(self, report: RadiologyReport, imaging: ImagingInfo) -> float:
|
| 403 |
+
score = 0.45
|
| 404 |
+
f = report.findings
|
| 405 |
+
|
| 406 |
+
if len(f) > 40: score += 0.10
|
| 407 |
+
if len(f) > 100: score += 0.05
|
| 408 |
+
|
| 409 |
+
anatomy = {
|
| 410 |
+
"brain": ["parenchyma", "ventricle", "sulci", "cortex",
|
| 411 |
+
"signal", "midline", "fossa", "white matter"],
|
| 412 |
+
"chest": ["lung", "cardiac", "pleural", "mediastinum",
|
| 413 |
+
"consolidation", "silhouette", "costophrenic"],
|
| 414 |
+
"pelvis": ["femoral", "acetabul", "iliac", "hip", "sacrum",
|
| 415 |
+
"hardware", "screw", "fixation", "prosthes"],
|
| 416 |
+
"abdomen": ["liver", "spleen", "kidney", "bowel",
|
| 417 |
+
"pancreas", "peritoneal", "aorta"],
|
| 418 |
+
"spine": ["vertebr", "disc", "canal", "foramina",
|
| 419 |
+
"alignment", "cord", "facet"],
|
| 420 |
+
"knee": ["condyle", "meniscus", "patella", "tibial",
|
| 421 |
+
"ligament", "joint space"],
|
| 422 |
+
}
|
| 423 |
+
region_key = imaging.body_region.lower()
|
| 424 |
+
terms = anatomy.get(region_key, [])
|
| 425 |
+
matched = sum(1 for t in terms if t in f.lower())
|
| 426 |
+
score += min(matched * 0.03, 0.15)
|
| 427 |
+
|
| 428 |
+
imp = report.impression
|
| 429 |
+
if len(imp) > 30: score += 0.08
|
| 430 |
+
good = ["consistent with", "no evidence", "no acute", "identified",
|
| 431 |
+
"findings", "normal", "unremarkable", "in keeping with"]
|
| 432 |
+
if any(p in imp.lower() for p in good):
|
| 433 |
+
score += 0.08
|
| 434 |
+
if "clinical presentation of a" not in imp:
|
| 435 |
+
score += 0.05
|
| 436 |
+
|
| 437 |
+
if len(report.recommendations) > 20:
|
| 438 |
+
score += 0.04
|
| 439 |
+
|
| 440 |
+
return round(max(0.45, min(0.88, score)), 2)
|
requirements.txt
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# Radiology AI System — Requirements
|
| 3 |
+
# Meditron-7B + BLIP-2 + RTX 4060 8GB
|
| 4 |
+
# ============================================================
|
| 5 |
+
|
| 6 |
+
# Core API
|
| 7 |
+
fastapi>=0.111.0
|
| 8 |
+
uvicorn[standard]>=0.29.0
|
| 9 |
+
python-multipart>=0.0.9
|
| 10 |
+
|
| 11 |
+
# AI / ML — install torch separately with CUDA first
|
| 12 |
+
transformers==4.51.3
|
| 13 |
+
accelerate>=0.34.0
|
| 14 |
+
bitsandbytes>=0.43.0
|
| 15 |
+
peft>=0.11.0
|
| 16 |
+
sentencepiece>=0.2.0
|
| 17 |
+
protobuf>=4.25.0
|
| 18 |
+
|
| 19 |
+
# Fine-tuning
|
| 20 |
+
unsloth
|
| 21 |
+
unsloth_zoo
|
| 22 |
+
trl==0.18.2
|
| 23 |
+
datasets==4.3.0
|
| 24 |
+
|
| 25 |
+
# Image processing
|
| 26 |
+
Pillow>=10.3.0
|
| 27 |
+
opencv-python>=4.9.0
|
| 28 |
+
numpy>=1.26.0
|
| 29 |
+
|
| 30 |
+
# Frontend
|
| 31 |
+
gradio>=4.44.0
|
| 32 |
+
|
| 33 |
+
# PDF export
|
| 34 |
+
reportlab>=4.2.0
|
| 35 |
+
|
| 36 |
+
# Utilities
|
| 37 |
+
python-dotenv>=1.0.0
|
| 38 |
+
pandas>=2.2.0
|
| 39 |
+
requests>=2.32.0
|
| 40 |
+
tqdm>=4.66.0
|
| 41 |
+
msgspec
|
| 42 |
+
torchao
|
| 43 |
+
triton-windows
|
run_backend.bat
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
echo Starting Radiology AI Backend (BioMistral-7B + BLIP-2)...
|
| 3 |
+
call venv\Scripts\activate
|
| 4 |
+
set USE_4BIT=true
|
| 5 |
+
set ENABLE_VISION_CAPTION=true
|
| 6 |
+
set SLM_MODEL_ID=BioMistral/BioMistral-7B
|
| 7 |
+
uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
|
run_frontend.bat
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
echo Starting Radiology AI Frontend...
|
| 3 |
+
call venv\Scripts\activate
|
| 4 |
+
python frontend/ui.py
|
run_training.bat
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
echo ============================================================
|
| 3 |
+
echo Fine-tuning Meditron-7B on unsloth/Radiology_mini
|
| 4 |
+
echo Time: ~45 minutes on RTX 4060
|
| 5 |
+
echo ============================================================
|
| 6 |
+
call venv\Scripts\activate
|
| 7 |
+
python training/train_unsloth.py --max_steps 300 --batch_size 1 --lora_r 8
|
setup.bat
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
echo ============================================================
|
| 3 |
+
echo Radiology AI System — Setup Script
|
| 4 |
+
echo Run this ONCE after downloading the project
|
| 5 |
+
echo ============================================================
|
| 6 |
+
|
| 7 |
+
echo.
|
| 8 |
+
echo [1/4] Creating virtual environment...
|
| 9 |
+
python -m venv venv
|
| 10 |
+
call venv\Scripts\activate
|
| 11 |
+
|
| 12 |
+
echo.
|
| 13 |
+
echo [2/4] Installing PyTorch with CUDA 12.8...
|
| 14 |
+
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
|
| 15 |
+
|
| 16 |
+
echo.
|
| 17 |
+
echo [3/4] Installing all dependencies...
|
| 18 |
+
pip install -r requirements.txt
|
| 19 |
+
|
| 20 |
+
echo.
|
| 21 |
+
echo [4/4] Verifying GPU...
|
| 22 |
+
python -c "import torch; print('CUDA:', torch.cuda.is_available()); print('GPU:', torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'None')"
|
| 23 |
+
|
| 24 |
+
echo.
|
| 25 |
+
echo ============================================================
|
| 26 |
+
echo Setup complete!
|
| 27 |
+
echo Next steps:
|
| 28 |
+
echo 1. Fine-tune: run_training.bat
|
| 29 |
+
echo 2. Start app: run_backend.bat (Terminal 1)
|
| 30 |
+
echo run_frontend.bat (Terminal 2)
|
| 31 |
+
echo ============================================================
|
| 32 |
+
pause
|
ui.py
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ui.py - Gradio 6.x compatible UI - Extended timeout version
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import gradio as gr
|
| 11 |
+
import httpx
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
|
| 16 |
+
REQUEST_TIMEOUT = 600 # 10 minutes — Phi-2 on CPU is slow
|
| 17 |
+
|
| 18 |
+
CUSTOM_CSS = """
|
| 19 |
+
.gr-markdown h1 { font-size: 2rem !important; color: #1a4a8a !important; font-weight: 700; border-bottom: 3px solid #1a4a8a; padding-bottom: 8px; }
|
| 20 |
+
.gr-markdown h3 { color: #1a4a8a !important; font-weight: 600; }
|
| 21 |
+
#report-box textarea { font-family: 'Courier New', monospace !important; font-size: 13px !important; line-height: 1.7 !important; background: #1e1e2e !important; color: #e0e0e0 !important; border: 1.5px solid #1a4a8a !important; border-radius: 8px !important; }
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
def _check_backend() -> bool:
|
| 25 |
+
try:
|
| 26 |
+
r = httpx.get(f"{BACKEND_URL}/api/health", timeout=5)
|
| 27 |
+
return r.status_code == 200
|
| 28 |
+
except Exception:
|
| 29 |
+
return False
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _call_generate_report(image_path, patient_name, patient_age, patient_gender,
|
| 33 |
+
symptoms, clinical_history, modality, body_region, view):
|
| 34 |
+
with open(image_path, "rb") as f:
|
| 35 |
+
image_bytes = f.read()
|
| 36 |
+
filename = Path(image_path).name
|
| 37 |
+
|
| 38 |
+
with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
|
| 39 |
+
response = client.post(
|
| 40 |
+
f"{BACKEND_URL}/api/generate-report",
|
| 41 |
+
files={"image": (filename, image_bytes, "application/octet-stream")},
|
| 42 |
+
data={
|
| 43 |
+
"patient_name": patient_name,
|
| 44 |
+
"patient_age": str(patient_age),
|
| 45 |
+
"patient_gender": patient_gender,
|
| 46 |
+
"symptoms": symptoms,
|
| 47 |
+
"clinical_history": clinical_history,
|
| 48 |
+
"modality": modality,
|
| 49 |
+
"body_region": body_region,
|
| 50 |
+
"view": view,
|
| 51 |
+
},
|
| 52 |
+
)
|
| 53 |
+
response.raise_for_status()
|
| 54 |
+
return response.json()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def generate_report(image_file, patient_name, patient_age, patient_gender,
|
| 58 |
+
symptoms, clinical_history, modality, body_region, view):
|
| 59 |
+
if image_file is None:
|
| 60 |
+
return "", "", "⚠️ Please upload a radiology image.", "", "❌ No image uploaded."
|
| 61 |
+
if not patient_name or not patient_name.strip():
|
| 62 |
+
return "", "", "⚠️ Please enter the patient name.", "", "❌ Patient name required."
|
| 63 |
+
if not patient_age or int(patient_age) <= 0:
|
| 64 |
+
return "", "", "⚠️ Please enter a valid patient age.", "", "❌ Invalid age."
|
| 65 |
+
if not symptoms.strip():
|
| 66 |
+
return "", "", "⚠️ Please enter presenting symptoms.", "", "❌ Symptoms required."
|
| 67 |
+
if not _check_backend():
|
| 68 |
+
return "", "", "❌ Backend offline. Run: uvicorn backend.main:app --reload", "", "❌ Backend offline."
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
data = _call_generate_report(
|
| 72 |
+
image_path=image_file,
|
| 73 |
+
patient_name=patient_name.strip(),
|
| 74 |
+
patient_age=int(patient_age),
|
| 75 |
+
patient_gender=patient_gender,
|
| 76 |
+
symptoms=symptoms,
|
| 77 |
+
clinical_history=clinical_history,
|
| 78 |
+
modality=modality,
|
| 79 |
+
body_region=body_region,
|
| 80 |
+
view=view,
|
| 81 |
+
)
|
| 82 |
+
except httpx.ReadTimeout:
|
| 83 |
+
err = "❌ Request timed out — model is still processing. Wait 2 min and try again, or restart backend."
|
| 84 |
+
return "", "", err, "", err
|
| 85 |
+
except httpx.HTTPStatusError as exc:
|
| 86 |
+
err = f"❌ Server error {exc.response.status_code}: {exc.response.text[:200]}"
|
| 87 |
+
return "", "", err, "", err
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
err = f"❌ Request failed: {exc}"
|
| 90 |
+
return "", "", err, "", err
|
| 91 |
+
|
| 92 |
+
report = data.get("report", {})
|
| 93 |
+
validation = data.get("validation", {})
|
| 94 |
+
report_id = data.get("report_id", "")
|
| 95 |
+
confidence = validation.get("confidence_score", 0.0)
|
| 96 |
+
gen_time = report.get("generation_time_seconds", 0)
|
| 97 |
+
|
| 98 |
+
report_text = data.get("report_text", "")
|
| 99 |
+
if not report_text:
|
| 100 |
+
r = data.get("report", {})
|
| 101 |
+
report_text = f"""RADIOLOGY REPORT
|
| 102 |
+
{'='*60}
|
| 103 |
+
|
| 104 |
+
EXAMINATION DETAILS:
|
| 105 |
+
{r.get('examination_details','—')}
|
| 106 |
+
|
| 107 |
+
CLINICAL INFORMATION:
|
| 108 |
+
{r.get('clinical_information','—')}
|
| 109 |
+
|
| 110 |
+
FINDINGS:
|
| 111 |
+
{r.get('findings','—')}
|
| 112 |
+
|
| 113 |
+
IMPRESSION:
|
| 114 |
+
{r.get('impression','—')}
|
| 115 |
+
|
| 116 |
+
RECOMMENDATIONS:
|
| 117 |
+
{r.get('recommendations','—')}
|
| 118 |
+
|
| 119 |
+
CONFIDENCE SCORE: {confidence:.2f}
|
| 120 |
+
{'='*60}
|
| 121 |
+
END OF REPORT"""
|
| 122 |
+
|
| 123 |
+
if confidence >= 0.80:
|
| 124 |
+
conf_label, conf_color = "HIGH", "#138808"
|
| 125 |
+
elif confidence >= 0.60:
|
| 126 |
+
conf_label, conf_color = "MODERATE", "#e67e00"
|
| 127 |
+
else:
|
| 128 |
+
conf_label, conf_color = "LOW", "#c0392b"
|
| 129 |
+
|
| 130 |
+
confidence_html = f"""
|
| 131 |
+
<div style="text-align:center;padding:16px;background:#f0f6ff;
|
| 132 |
+
border:2px solid #1a4a8a;border-radius:12px;margin-top:8px;">
|
| 133 |
+
<div style="font-size:0.85rem;color:#555;margin-bottom:4px;">CONFIDENCE SCORE</div>
|
| 134 |
+
<div style="font-size:1.5rem;font-weight:bold;color:{conf_color};">
|
| 135 |
+
{confidence:.2f} — {conf_label}
|
| 136 |
+
</div>
|
| 137 |
+
<div style="font-size:0.78rem;color:#888;margin-top:6px;">
|
| 138 |
+
Generated in {gen_time:.1f}s
|
| 139 |
+
</div>
|
| 140 |
+
</div>"""
|
| 141 |
+
|
| 142 |
+
issues = validation.get("issues", [])
|
| 143 |
+
warnings = validation.get("warnings", [])
|
| 144 |
+
suggestions = validation.get("suggestions", [])
|
| 145 |
+
section_scores = validation.get("section_scores", {})
|
| 146 |
+
|
| 147 |
+
val_lines = ["**Validation Results**\n"]
|
| 148 |
+
val_lines.append(f"{'✅ VALID' if validation.get('is_valid') else '⚠️ REVIEW NEEDED'}")
|
| 149 |
+
if section_scores:
|
| 150 |
+
val_lines.append("\n**Section Scores:**")
|
| 151 |
+
for sec, score in section_scores.items():
|
| 152 |
+
bar = "█" * int(score * 10) + "░" * (10 - int(score * 10))
|
| 153 |
+
val_lines.append(f" {sec.replace('_',' ').title()}: {bar} {score:.2f}")
|
| 154 |
+
if issues:
|
| 155 |
+
val_lines.append("\n**Issues:**")
|
| 156 |
+
val_lines.extend(f" ❌ {i}" for i in issues)
|
| 157 |
+
if warnings:
|
| 158 |
+
val_lines.append("\n**Warnings:**")
|
| 159 |
+
val_lines.extend(f" ⚠️ {w}" for w in warnings)
|
| 160 |
+
if suggestions:
|
| 161 |
+
val_lines.append("\n**Suggestions:**")
|
| 162 |
+
val_lines.extend(f" 💡 {s}" for s in suggestions)
|
| 163 |
+
|
| 164 |
+
# Medical Coding Block
|
| 165 |
+
coding = data.get("medical_coding", {})
|
| 166 |
+
val_lines.append("\n---")
|
| 167 |
+
val_lines.append("## Medical Coding (UMLS / ICD-10 / SNOMED CT)")
|
| 168 |
+
if coding and coding.get("total_findings", 0) > 0:
|
| 169 |
+
primary = coding.get("primary_diagnosis", {})
|
| 170 |
+
val_lines.append(f"**Primary Diagnosis:** {primary.get('label','—')} | CUI: {primary.get('cui','—')}")
|
| 171 |
+
val_lines.append(f"**Overall Severity:** {coding.get('overall_severity','—')}")
|
| 172 |
+
icd_list = coding.get("icd10_codes", [])
|
| 173 |
+
if icd_list:
|
| 174 |
+
val_lines.append(f"**ICD-10 Codes:** {' | '.join(icd_list)}")
|
| 175 |
+
val_lines.append(f"**All Concepts Found ({coding.get('total_findings',0)}):**")
|
| 176 |
+
for mc in coding.get("medical_codes", []):
|
| 177 |
+
val_lines.append(f" * {mc['label']} | CUI: {mc['cui']} | ICD-10: {mc['icd10']} | Severity: {mc['severity']}")
|
| 178 |
+
else:
|
| 179 |
+
val_lines.append("No significant pathological concepts detected (normal study).")
|
| 180 |
+
|
| 181 |
+
validation_text = "\n".join(val_lines)
|
| 182 |
+
status_final = f"✅ Report generated | ID: {report_id[:8]}"
|
| 183 |
+
return report_text, confidence_html, validation_text, report_id, status_final
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _downloads_dir() -> Path:
|
| 187 |
+
"""Returns a temp download folder that works on both Windows and Linux."""
|
| 188 |
+
d = Path(os.environ.get("TEMP", os.environ.get("TMP", "/tmp"))) / "radiology_reports"
|
| 189 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 190 |
+
return d
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def export_pdf(report_id):
|
| 194 |
+
if not report_id:
|
| 195 |
+
return None
|
| 196 |
+
try:
|
| 197 |
+
r = httpx.get(f"{BACKEND_URL}/api/export/pdf/{report_id}", timeout=60)
|
| 198 |
+
r.raise_for_status()
|
| 199 |
+
path = _downloads_dir() / f"radiology_report_{report_id[:8]}.pdf"
|
| 200 |
+
with open(path, "wb") as f:
|
| 201 |
+
f.write(r.content)
|
| 202 |
+
logger.info(f"PDF saved: {path}")
|
| 203 |
+
return str(path)
|
| 204 |
+
except Exception as exc:
|
| 205 |
+
logger.error(f"PDF export error: {exc}")
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def export_json_file(report_id):
|
| 210 |
+
if not report_id:
|
| 211 |
+
return None
|
| 212 |
+
try:
|
| 213 |
+
r = httpx.get(f"{BACKEND_URL}/api/export/json/{report_id}", timeout=30)
|
| 214 |
+
r.raise_for_status()
|
| 215 |
+
path = _downloads_dir() / f"radiology_report_{report_id[:8]}.json"
|
| 216 |
+
with open(path, "wb") as f:
|
| 217 |
+
f.write(r.content)
|
| 218 |
+
logger.info(f"JSON saved: {path}")
|
| 219 |
+
return str(path)
|
| 220 |
+
except Exception as exc:
|
| 221 |
+
logger.error(f"JSON export error: {exc}")
|
| 222 |
+
return None
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def fill_demo():
|
| 226 |
+
return (
|
| 227 |
+
45, "Male",
|
| 228 |
+
"Left abdominal pain, swelling of the abdominal wall, fever",
|
| 229 |
+
"Suspected abdominal wall infection.",
|
| 230 |
+
"CT", "Abdomen", "Axial",
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def check_backend_status():
|
| 235 |
+
if _check_backend():
|
| 236 |
+
return "🟢 Backend online — ready to generate reports."
|
| 237 |
+
return "🔴 Backend offline — run: uvicorn backend.main:app --reload"
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def fetch_report_history():
|
| 241 |
+
"""Fetch all reports from DB for history tab."""
|
| 242 |
+
try:
|
| 243 |
+
r = httpx.get(f"{BACKEND_URL}/api/reports?limit=50", timeout=10)
|
| 244 |
+
r.raise_for_status()
|
| 245 |
+
reports = r.json().get("reports", [])
|
| 246 |
+
if not reports:
|
| 247 |
+
return "No reports found in database yet."
|
| 248 |
+
lines = ["| Patient Name | Age | Gender | Modality | Region | Confidence | Date |",
|
| 249 |
+
"|---|---|---|---|---|---|---|"]
|
| 250 |
+
for rep in reports:
|
| 251 |
+
conf = rep.get("confidence_score", 0) or 0
|
| 252 |
+
lines.append(
|
| 253 |
+
f"| {rep.get('patient_name','—')} "
|
| 254 |
+
f"| {rep.get('age','—')} "
|
| 255 |
+
f"| {rep.get('gender','—')} "
|
| 256 |
+
f"| {rep.get('modality','—')} "
|
| 257 |
+
f"| {rep.get('body_region','—')} "
|
| 258 |
+
f"| {conf:.2f} "
|
| 259 |
+
f"| {str(rep.get('created_at','—'))[:16]} |"
|
| 260 |
+
)
|
| 261 |
+
return "\n".join(lines)
|
| 262 |
+
except Exception as e:
|
| 263 |
+
return f"❌ Could not load history: {e}"
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def fetch_db_stats():
|
| 267 |
+
"""Fetch database statistics."""
|
| 268 |
+
try:
|
| 269 |
+
r = httpx.get(f"{BACKEND_URL}/api/stats", timeout=10)
|
| 270 |
+
r.raise_for_status()
|
| 271 |
+
s = r.json()
|
| 272 |
+
mod_counts = s.get("modality_counts", {})
|
| 273 |
+
mod_text = " | ".join(f"{k}: {v}" for k,v in mod_counts.items())
|
| 274 |
+
return (
|
| 275 |
+
f"**Total Reports:** {s.get('total_reports', 0)} \n"
|
| 276 |
+
f"**Unique Patients:** {s.get('total_patients', 0)} \n"
|
| 277 |
+
f"**Avg Confidence:** {s.get('avg_confidence', 0):.2f} \n"
|
| 278 |
+
f"**By Modality:** {mod_text or 'None yet'}"
|
| 279 |
+
)
|
| 280 |
+
except Exception as e:
|
| 281 |
+
return f"❌ Could not load stats: {e}"
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def search_patient_reports(name: str):
|
| 285 |
+
"""Search reports by patient name."""
|
| 286 |
+
if not name.strip():
|
| 287 |
+
return "Enter a patient name to search."
|
| 288 |
+
try:
|
| 289 |
+
r = httpx.get(
|
| 290 |
+
f"{BACKEND_URL}/api/reports/patient/{name.strip()}",
|
| 291 |
+
timeout=10
|
| 292 |
+
)
|
| 293 |
+
r.raise_for_status()
|
| 294 |
+
data = r.json()
|
| 295 |
+
reports = data.get("reports", [])
|
| 296 |
+
if not reports:
|
| 297 |
+
return f"No reports found for patient: {name}"
|
| 298 |
+
lines = [f"**{len(reports)} report(s) found for: {name}**\n",
|
| 299 |
+
"| Date | Modality | Region | Confidence | Impression |",
|
| 300 |
+
"|---|---|---|---|---|"]
|
| 301 |
+
for rep in reports:
|
| 302 |
+
conf = rep.get("confidence_score", 0) or 0
|
| 303 |
+
imp = str(rep.get("impression","—"))[:60] + "..."
|
| 304 |
+
lines.append(
|
| 305 |
+
f"| {str(rep.get('created_at','—'))[:16]} "
|
| 306 |
+
f"| {rep.get('modality','—')} "
|
| 307 |
+
f"| {rep.get('body_region','—')} "
|
| 308 |
+
f"| {conf:.2f} "
|
| 309 |
+
f"| {imp} |"
|
| 310 |
+
)
|
| 311 |
+
return "\n".join(lines)
|
| 312 |
+
except Exception as e:
|
| 313 |
+
return f"❌ Search failed: {e}"
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def get_report_uuids_for_patient(name: str):
|
| 317 |
+
"""Returns list of (label, uuid) tuples for a patient's reports."""
|
| 318 |
+
if not name.strip():
|
| 319 |
+
return []
|
| 320 |
+
try:
|
| 321 |
+
r = httpx.get(
|
| 322 |
+
f"{BACKEND_URL}/api/reports/patient/{name.strip()}", timeout=10
|
| 323 |
+
)
|
| 324 |
+
r.raise_for_status()
|
| 325 |
+
reports = r.json().get("reports", [])
|
| 326 |
+
choices = []
|
| 327 |
+
for rep in reports:
|
| 328 |
+
label = (
|
| 329 |
+
f"{str(rep.get('created_at',''))[:16]} | "
|
| 330 |
+
f"{rep.get('modality','—')} {rep.get('body_region','—')} | "
|
| 331 |
+
f"Conf: {rep.get('confidence_score',0):.2f}"
|
| 332 |
+
)
|
| 333 |
+
choices.append((label, rep.get("report_uuid", "")))
|
| 334 |
+
return choices
|
| 335 |
+
except Exception:
|
| 336 |
+
return []
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def load_patient_report_choices(name: str):
|
| 340 |
+
"""Populate the dropdown with patient's reports."""
|
| 341 |
+
choices = get_report_uuids_for_patient(name)
|
| 342 |
+
if not choices:
|
| 343 |
+
return gr.update(choices=[], value=None,
|
| 344 |
+
label="No reports found for this patient")
|
| 345 |
+
return gr.update(choices=choices, value=choices[0][1],
|
| 346 |
+
label=f"{len(choices)} report(s) found — select one to download")
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def download_report_pdf(report_uuid: str):
|
| 350 |
+
"""Download PDF for a specific report UUID from DB."""
|
| 351 |
+
if not report_uuid:
|
| 352 |
+
return None
|
| 353 |
+
try:
|
| 354 |
+
r = httpx.get(
|
| 355 |
+
f"{BACKEND_URL}/api/export/pdf/{report_uuid}", timeout=60
|
| 356 |
+
)
|
| 357 |
+
r.raise_for_status()
|
| 358 |
+
path = _downloads_dir() / f"report_{report_uuid[:8]}.pdf"
|
| 359 |
+
with open(path, "wb") as f:
|
| 360 |
+
f.write(r.content)
|
| 361 |
+
return str(path)
|
| 362 |
+
except Exception as exc:
|
| 363 |
+
logger.error(f"PDF download failed: {exc}")
|
| 364 |
+
return None
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def download_report_json(report_uuid: str):
|
| 368 |
+
"""Download JSON for a specific report UUID from DB."""
|
| 369 |
+
if not report_uuid:
|
| 370 |
+
return None
|
| 371 |
+
try:
|
| 372 |
+
r = httpx.get(
|
| 373 |
+
f"{BACKEND_URL}/api/export/json/{report_uuid}", timeout=30
|
| 374 |
+
)
|
| 375 |
+
r.raise_for_status()
|
| 376 |
+
path = _downloads_dir() / f"report_{report_uuid[:8]}.json"
|
| 377 |
+
with open(path, "wb") as f:
|
| 378 |
+
f.write(r.content)
|
| 379 |
+
return str(path)
|
| 380 |
+
except Exception as exc:
|
| 381 |
+
logger.error(f"JSON download failed: {exc}")
|
| 382 |
+
return None
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def preview_selected_report(report_uuid: str):
|
| 386 |
+
"""Show full report text for selected report UUID."""
|
| 387 |
+
if not report_uuid:
|
| 388 |
+
return "Select a report to preview."
|
| 389 |
+
try:
|
| 390 |
+
r = httpx.get(
|
| 391 |
+
f"{BACKEND_URL}/api/report/{report_uuid}", timeout=10
|
| 392 |
+
)
|
| 393 |
+
r.raise_for_status()
|
| 394 |
+
data = r.json()
|
| 395 |
+
rep = data.get("report", {})
|
| 396 |
+
pat = data.get("patient", {})
|
| 397 |
+
img = data.get("imaging", {})
|
| 398 |
+
valid = data.get("validation", {})
|
| 399 |
+
conf = valid.get("confidence_score", rep.get("confidence_score", 0)) or 0
|
| 400 |
+
name = data.get("patient_name", pat.get("name", "Unknown"))
|
| 401 |
+
return f"""RADIOLOGY REPORT
|
| 402 |
+
{'='*60}
|
| 403 |
+
Patient Name : {name}
|
| 404 |
+
Age / Gender : {pat.get('age','—')} / {pat.get('gender','—')}
|
| 405 |
+
Study : {img.get('modality','—')} {img.get('body_region','—')} {img.get('view','') or ''}
|
| 406 |
+
Date : {str(data.get('timestamp',''))[:16]}
|
| 407 |
+
{'='*60}
|
| 408 |
+
|
| 409 |
+
EXAMINATION DETAILS:
|
| 410 |
+
{rep.get('examination_details','—')}
|
| 411 |
+
|
| 412 |
+
CLINICAL INFORMATION:
|
| 413 |
+
{rep.get('clinical_information','—')}
|
| 414 |
+
|
| 415 |
+
FINDINGS:
|
| 416 |
+
{rep.get('findings','—')}
|
| 417 |
+
|
| 418 |
+
IMPRESSION:
|
| 419 |
+
{rep.get('impression','—')}
|
| 420 |
+
|
| 421 |
+
RECOMMENDATIONS:
|
| 422 |
+
{rep.get('recommendations','—')}
|
| 423 |
+
|
| 424 |
+
CONFIDENCE SCORE: {conf:.2f}
|
| 425 |
+
{'='*60}
|
| 426 |
+
END OF REPORT"""
|
| 427 |
+
except Exception as exc:
|
| 428 |
+
return f"❌ Could not load report: {exc}"
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def build_interface() -> gr.Blocks:
|
| 432 |
+
with gr.Blocks(title="Radiology AI — Report Generation System") as demo:
|
| 433 |
+
|
| 434 |
+
report_id_state = gr.State("")
|
| 435 |
+
|
| 436 |
+
gr.Markdown(
|
| 437 |
+
"# 🏥 Radiology AI — Report Generation System\n"
|
| 438 |
+
"### AI-Based Structured Radiology Report Generation with Clinical Validation & Patient Database"
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
backend_status = gr.Markdown("🔴 Checking backend…")
|
| 442 |
+
|
| 443 |
+
with gr.Tabs():
|
| 444 |
+
|
| 445 |
+
# ── TAB 1: Generate Report ─────────────────────────────────
|
| 446 |
+
with gr.Tab("🚀 Generate Report"):
|
| 447 |
+
gr.Markdown(
|
| 448 |
+
"> ⏳ **Note:** First report generation takes **30–60 seconds on GPU**. "
|
| 449 |
+
"Please click Generate Report and wait — do not click again."
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
with gr.Row(equal_height=False):
|
| 453 |
+
|
| 454 |
+
# LEFT: Inputs
|
| 455 |
+
with gr.Column(scale=1, min_width=340):
|
| 456 |
+
|
| 457 |
+
gr.Markdown("### 📤 Step 1: Upload Radiology Image")
|
| 458 |
+
image_input = gr.File(
|
| 459 |
+
label="Upload Image (.dcm / .png / .jpg)",
|
| 460 |
+
file_types=[".dcm", ".png", ".jpg", ".jpeg"],
|
| 461 |
+
type="filepath",
|
| 462 |
+
)
|
| 463 |
+
image_preview = gr.Image(
|
| 464 |
+
label="Preview",
|
| 465 |
+
interactive=False,
|
| 466 |
+
height=220,
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
gr.Markdown("### 👤 Step 2: Patient Information")
|
| 470 |
+
patient_name = gr.Textbox(
|
| 471 |
+
label="Patient Name",
|
| 472 |
+
placeholder="e.g. John Smith",
|
| 473 |
+
lines=1,
|
| 474 |
+
)
|
| 475 |
+
with gr.Row():
|
| 476 |
+
patient_age = gr.Number(
|
| 477 |
+
label="Age (years)", minimum=0, maximum=130, step=1, value=50
|
| 478 |
+
)
|
| 479 |
+
patient_gender = gr.Dropdown(
|
| 480 |
+
label="Gender",
|
| 481 |
+
choices=["Male", "Female", "Other"],
|
| 482 |
+
value="Male",
|
| 483 |
+
)
|
| 484 |
+
symptoms = gr.Textbox(
|
| 485 |
+
label="Presenting Symptoms",
|
| 486 |
+
placeholder="e.g. abdominal pain, fever, swelling",
|
| 487 |
+
lines=2,
|
| 488 |
+
)
|
| 489 |
+
clinical_history = gr.Textbox(
|
| 490 |
+
label="Clinical History",
|
| 491 |
+
placeholder="e.g. suspected infection, prior surgeries",
|
| 492 |
+
lines=2,
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
gr.Markdown("### 🔬 Step 3: Imaging Information")
|
| 496 |
+
with gr.Row():
|
| 497 |
+
modality = gr.Dropdown(
|
| 498 |
+
label="Modality",
|
| 499 |
+
choices=[
|
| 500 |
+
"X-ray", "CT", "MRI", "Ultrasound", "PET",
|
| 501 |
+
"Mammography", "Fluoroscopy", "Angiography",
|
| 502 |
+
"Nuclear Medicine",
|
| 503 |
+
],
|
| 504 |
+
value="X-ray",
|
| 505 |
+
)
|
| 506 |
+
body_region = gr.Dropdown(
|
| 507 |
+
label="Body Region",
|
| 508 |
+
choices=[
|
| 509 |
+
"Chest", "Brain", "Abdomen", "Pelvis",
|
| 510 |
+
"Spine", "Knee", "Hip", "Shoulder", "Neck",
|
| 511 |
+
"Cardiac", "Breast", "Liver", "Kidney",
|
| 512 |
+
"Thyroid", "Hand", "Wrist", "Foot", "Ankle",
|
| 513 |
+
"Skull", "Prostate", "Obstetric", "Vascular",
|
| 514 |
+
],
|
| 515 |
+
value="Chest",
|
| 516 |
+
)
|
| 517 |
+
view = gr.Dropdown(
|
| 518 |
+
label="View / Projection",
|
| 519 |
+
choices=[
|
| 520 |
+
"PA", "AP", "Lateral", "Axial", "Coronal",
|
| 521 |
+
"Sagittal", "Oblique", "Left Lateral", "Right Lateral",
|
| 522 |
+
"Supine", "Prone", "Decubitus", "Cephalocaudal (CC)",
|
| 523 |
+
"Mediolateral Oblique (MLO)", "T1", "T2", "FLAIR",
|
| 524 |
+
"DWI", "STIR", "",
|
| 525 |
+
],
|
| 526 |
+
value="PA",
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
with gr.Row():
|
| 530 |
+
demo_btn = gr.Button("📋 Fill Demo", variant="secondary", size="sm")
|
| 531 |
+
generate_btn = gr.Button("🚀 Generate Report", variant="primary", size="lg")
|
| 532 |
+
|
| 533 |
+
status_label = gr.Markdown("")
|
| 534 |
+
|
| 535 |
+
# RIGHT: Outputs
|
| 536 |
+
with gr.Column(scale=2, min_width=480):
|
| 537 |
+
|
| 538 |
+
gr.Markdown("### 📄 Generated Radiology Report")
|
| 539 |
+
report_output = gr.Textbox(
|
| 540 |
+
label="Structured Report",
|
| 541 |
+
lines=22,
|
| 542 |
+
interactive=False,
|
| 543 |
+
elem_id="report-box",
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
confidence_output = gr.HTML(
|
| 547 |
+
value="<div style='text-align:center;color:#aaa;padding:16px;'>"
|
| 548 |
+
"Confidence score will appear here after generation.</div>"
|
| 549 |
+
)
|
| 550 |
+
|
| 551 |
+
with gr.Accordion("📊 Validation Details", open=False):
|
| 552 |
+
validation_output = gr.Markdown("")
|
| 553 |
+
|
| 554 |
+
gr.Markdown("### 💾 Export Report")
|
| 555 |
+
with gr.Row():
|
| 556 |
+
export_pdf_btn = gr.Button("⬇️ Download PDF", variant="secondary")
|
| 557 |
+
export_json_btn = gr.Button("⬇️ Download JSON", variant="secondary")
|
| 558 |
+
|
| 559 |
+
pdf_file = gr.File(label="PDF Report", visible=False)
|
| 560 |
+
json_file = gr.File(label="JSON Report", visible=False)
|
| 561 |
+
|
| 562 |
+
# ── TAB 2: Patient History ─────────────────────────────────
|
| 563 |
+
with gr.Tab("📋 Patient History"):
|
| 564 |
+
gr.Markdown("## 📋 Report History Database")
|
| 565 |
+
|
| 566 |
+
with gr.Row():
|
| 567 |
+
with gr.Column(scale=1):
|
| 568 |
+
gr.Markdown("### 🔍 Search by Patient Name")
|
| 569 |
+
search_input = gr.Textbox(
|
| 570 |
+
label="Patient Name", placeholder="e.g. John Smith"
|
| 571 |
+
)
|
| 572 |
+
search_btn = gr.Button("🔍 Search", variant="primary")
|
| 573 |
+
search_output = gr.Markdown("")
|
| 574 |
+
|
| 575 |
+
with gr.Column(scale=1):
|
| 576 |
+
gr.Markdown("### 📊 Database Statistics")
|
| 577 |
+
stats_output = gr.Markdown("")
|
| 578 |
+
refresh_stats_btn = gr.Button("🔄 Refresh Stats", variant="secondary")
|
| 579 |
+
|
| 580 |
+
# ── Download Previous Reports ──────────────────────────
|
| 581 |
+
gr.Markdown("---")
|
| 582 |
+
gr.Markdown("### ⬇️ Download Previous Reports")
|
| 583 |
+
with gr.Row():
|
| 584 |
+
with gr.Column(scale=2):
|
| 585 |
+
dl_patient_input = gr.Textbox(
|
| 586 |
+
label="Enter Patient Name to Load Reports",
|
| 587 |
+
placeholder="e.g. Gopalamanikandan A",
|
| 588 |
+
)
|
| 589 |
+
dl_load_btn = gr.Button(
|
| 590 |
+
"📂 Load Reports for This Patient",
|
| 591 |
+
variant="primary"
|
| 592 |
+
)
|
| 593 |
+
report_selector = gr.Dropdown(
|
| 594 |
+
label="Select Report to Download",
|
| 595 |
+
choices=[],
|
| 596 |
+
value=None,
|
| 597 |
+
interactive=True,
|
| 598 |
+
)
|
| 599 |
+
|
| 600 |
+
with gr.Column(scale=2):
|
| 601 |
+
gr.Markdown("#### 👁️ Report Preview")
|
| 602 |
+
report_preview = gr.Textbox(
|
| 603 |
+
label="Selected Report",
|
| 604 |
+
lines=18,
|
| 605 |
+
interactive=False,
|
| 606 |
+
elem_id="report-box",
|
| 607 |
+
)
|
| 608 |
+
|
| 609 |
+
with gr.Row():
|
| 610 |
+
dl_pdf_btn = gr.Button("⬇️ Download PDF", variant="secondary")
|
| 611 |
+
dl_json_btn = gr.Button("⬇️ Download JSON", variant="secondary")
|
| 612 |
+
dl_pdf_file = gr.File(label="PDF", visible=False)
|
| 613 |
+
dl_json_file = gr.File(label="JSON", visible=False)
|
| 614 |
+
|
| 615 |
+
# ── All Reports Table ──────────────────────────────────
|
| 616 |
+
gr.Markdown("---")
|
| 617 |
+
gr.Markdown("### 📜 All Reports")
|
| 618 |
+
history_btn = gr.Button("🔄 Load All Reports", variant="secondary")
|
| 619 |
+
history_output = gr.Markdown("")
|
| 620 |
+
|
| 621 |
+
# ── Events ────────────────────────────────────────────────────
|
| 622 |
+
image_input.change(fn=lambda f: f, inputs=image_input, outputs=image_preview)
|
| 623 |
+
|
| 624 |
+
demo_btn.click(
|
| 625 |
+
fn=fill_demo, inputs=[],
|
| 626 |
+
outputs=[patient_age, patient_gender, symptoms, clinical_history,
|
| 627 |
+
modality, body_region, view],
|
| 628 |
+
)
|
| 629 |
+
|
| 630 |
+
generate_btn.click(
|
| 631 |
+
fn=generate_report,
|
| 632 |
+
inputs=[image_input, patient_name, patient_age, patient_gender,
|
| 633 |
+
symptoms, clinical_history, modality, body_region, view],
|
| 634 |
+
outputs=[report_output, confidence_output, validation_output,
|
| 635 |
+
report_id_state, status_label],
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
+
export_pdf_btn.click(
|
| 639 |
+
fn=export_pdf, inputs=[report_id_state], outputs=[pdf_file],
|
| 640 |
+
).then(
|
| 641 |
+
fn=lambda f: gr.update(visible=f is not None),
|
| 642 |
+
inputs=[pdf_file], outputs=[pdf_file],
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
+
export_json_btn.click(
|
| 646 |
+
fn=export_json_file, inputs=[report_id_state], outputs=[json_file],
|
| 647 |
+
).then(
|
| 648 |
+
fn=lambda f: gr.update(visible=f is not None),
|
| 649 |
+
inputs=[json_file], outputs=[json_file],
|
| 650 |
+
)
|
| 651 |
+
|
| 652 |
+
# History tab events
|
| 653 |
+
history_btn.click(
|
| 654 |
+
fn=fetch_report_history, inputs=[], outputs=[history_output]
|
| 655 |
+
)
|
| 656 |
+
search_btn.click(
|
| 657 |
+
fn=search_patient_reports, inputs=[search_input], outputs=[search_output]
|
| 658 |
+
)
|
| 659 |
+
refresh_stats_btn.click(
|
| 660 |
+
fn=fetch_db_stats, inputs=[], outputs=[stats_output]
|
| 661 |
+
)
|
| 662 |
+
|
| 663 |
+
# Download previous report events
|
| 664 |
+
dl_load_btn.click(
|
| 665 |
+
fn=load_patient_report_choices,
|
| 666 |
+
inputs=[dl_patient_input],
|
| 667 |
+
outputs=[report_selector],
|
| 668 |
+
)
|
| 669 |
+
report_selector.change(
|
| 670 |
+
fn=preview_selected_report,
|
| 671 |
+
inputs=[report_selector],
|
| 672 |
+
outputs=[report_preview],
|
| 673 |
+
)
|
| 674 |
+
dl_pdf_btn.click(
|
| 675 |
+
fn=download_report_pdf,
|
| 676 |
+
inputs=[report_selector],
|
| 677 |
+
outputs=[dl_pdf_file],
|
| 678 |
+
).then(
|
| 679 |
+
fn=lambda f: gr.update(visible=f is not None),
|
| 680 |
+
inputs=[dl_pdf_file], outputs=[dl_pdf_file],
|
| 681 |
+
)
|
| 682 |
+
dl_json_btn.click(
|
| 683 |
+
fn=download_report_json,
|
| 684 |
+
inputs=[report_selector],
|
| 685 |
+
outputs=[dl_json_file],
|
| 686 |
+
).then(
|
| 687 |
+
fn=lambda f: gr.update(visible=f is not None),
|
| 688 |
+
inputs=[dl_json_file], outputs=[dl_json_file],
|
| 689 |
+
)
|
| 690 |
+
|
| 691 |
+
demo.load(fn=check_backend_status, outputs=[backend_status])
|
| 692 |
+
demo.load(fn=fetch_db_stats, outputs=[stats_output])
|
| 693 |
+
|
| 694 |
+
return demo
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
if __name__ == "__main__":
|
| 698 |
+
logging.basicConfig(level=logging.INFO)
|
| 699 |
+
interface = build_interface()
|
| 700 |
+
interface.launch(
|
| 701 |
+
server_name="0.0.0.0",
|
| 702 |
+
server_port=int(os.getenv("GRADIO_PORT", "7860")),
|
| 703 |
+
share=False,
|
| 704 |
+
show_error=True,
|
| 705 |
+
css=CUSTOM_CSS,
|
| 706 |
+
theme=gr.themes.Soft(primary_hue="blue", neutral_hue="slate"),
|
| 707 |
+
)
|
validation_module.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
validation_module.py
|
| 3 |
+
--------------------
|
| 4 |
+
Clinical validation module for radiology reports.
|
| 5 |
+
|
| 6 |
+
Performs:
|
| 7 |
+
1. Structural completeness check
|
| 8 |
+
2. Medical terminology validation
|
| 9 |
+
3. Logical consistency analysis (findings → impression)
|
| 10 |
+
4. Confidence score computation
|
| 11 |
+
5. Detailed validation feedback
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
import re
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from typing import Dict, List, Optional, Tuple
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Constants — Medical Terminology Dictionaries
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
# Positive radiological finding terms (suggest pathology is mentioned)
|
| 27 |
+
FINDING_TERMS = {
|
| 28 |
+
# Pulmonary
|
| 29 |
+
"opacity", "consolidation", "infiltrate", "effusion", "pneumothorax",
|
| 30 |
+
"atelectasis", "nodule", "mass", "lesion", "cardiomegaly", "pneumonia",
|
| 31 |
+
"edema", "fibrosis", "emphysema", "pleural", "cavitation", "calcification",
|
| 32 |
+
# Neuro
|
| 33 |
+
"hemorrhage", "infarct", "midline shift", "herniation", "hydrocephalus",
|
| 34 |
+
"atrophy", "gliosis", "white matter", "leukoencephalopathy",
|
| 35 |
+
# Abdominal
|
| 36 |
+
"hepatomegaly", "splenomegaly", "ascites", "obstruction", "perforation",
|
| 37 |
+
"ileus", "dilatation", "gallstone", "calculi", "adenopathy",
|
| 38 |
+
# Musculoskeletal
|
| 39 |
+
"fracture", "dislocation", "lytic", "sclerotic", "arthritis", "erosion",
|
| 40 |
+
"osteoporosis", "osteopenia", "hardware", "implant", "arthroplasty",
|
| 41 |
+
"healed", "healing", "surgical", "fixation", "screw", "prosthesis",
|
| 42 |
+
"joint", "bone", "density", "cortical", "trabecular", "alignment",
|
| 43 |
+
# Vascular
|
| 44 |
+
"stenosis", "occlusion", "aneurysm", "thrombosis", "calcified",
|
| 45 |
+
# Soft tissue
|
| 46 |
+
"swelling", "inflammation", "fluid", "collection", "thickening",
|
| 47 |
+
# Normal descriptors
|
| 48 |
+
"normal", "unremarkable", "no acute", "within normal limits", "clear",
|
| 49 |
+
"intact", "preserved", "well-healed", "satisfactory", "stable",
|
| 50 |
+
"demonstrates", "appears", "shows", "noted", "visible", "evident",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# Impression must include at least one of these conclusion terms
|
| 54 |
+
IMPRESSION_CONCLUSION_TERMS = {
|
| 55 |
+
"consistent with", "suggestive of", "findings indicate", "impression",
|
| 56 |
+
"likely", "possible", "probable", "no evidence", "cannot exclude",
|
| 57 |
+
"differential", "consider", "normal", "unremarkable", "recommend",
|
| 58 |
+
"represents", "compatible with", "appears to", "evidence of",
|
| 59 |
+
"healed", "healing", "demonstrates", "shows", "indicates",
|
| 60 |
+
"no significant", "well-healed", "satisfactory", "stable",
|
| 61 |
+
"there is", "there are", "findings are",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
# Recommendation terms
|
| 65 |
+
RECOMMENDATION_TERMS = {
|
| 66 |
+
"correlation", "follow-up", "repeat", "ct scan", "mri", "biopsy",
|
| 67 |
+
"consult", "refer", "further", "additional", "clinical", "evaluation",
|
| 68 |
+
"monitoring", "treatment", "management", "urgent", "emergent",
|
| 69 |
+
"recommended", "suggested", "advised", "periodic", "regular",
|
| 70 |
+
"orthopedic", "specialist", "physiotherapy", "physical therapy",
|
| 71 |
+
"bone density", "dexa", "rheumatologist", "scan", "imaging",
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# Required report sections
|
| 75 |
+
REQUIRED_SECTIONS = [
|
| 76 |
+
"examination_details",
|
| 77 |
+
"clinical_information",
|
| 78 |
+
"findings",
|
| 79 |
+
"impression",
|
| 80 |
+
"recommendations",
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
# Minimum word counts per section
|
| 84 |
+
MINIMUM_WORDS = {
|
| 85 |
+
"examination_details": 5,
|
| 86 |
+
"clinical_information": 8,
|
| 87 |
+
"findings": 10,
|
| 88 |
+
"impression": 5,
|
| 89 |
+
"recommendations": 5,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
# Data Models
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@dataclass
|
| 99 |
+
class ValidationResult:
|
| 100 |
+
is_valid: bool = True
|
| 101 |
+
confidence_score: float = 0.50
|
| 102 |
+
section_scores: Dict[str, float] = field(default_factory=dict)
|
| 103 |
+
issues: List[str] = field(default_factory=list)
|
| 104 |
+
warnings: List[str] = field(default_factory=list)
|
| 105 |
+
suggestions: List[str] = field(default_factory=list)
|
| 106 |
+
|
| 107 |
+
def to_dict(self) -> dict:
|
| 108 |
+
return {
|
| 109 |
+
"is_valid": self.is_valid,
|
| 110 |
+
"confidence_score": round(self.confidence_score, 3),
|
| 111 |
+
"section_scores": {k: round(v, 3) for k, v in self.section_scores.items()},
|
| 112 |
+
"issues": self.issues,
|
| 113 |
+
"warnings": self.warnings,
|
| 114 |
+
"suggestions": self.suggestions,
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
def summary(self) -> str:
|
| 118 |
+
status = "✓ VALID" if self.is_valid else "✗ INVALID"
|
| 119 |
+
lines = [
|
| 120 |
+
f"Validation Status : {status}",
|
| 121 |
+
f"Confidence Score : {self.confidence_score:.2f}",
|
| 122 |
+
]
|
| 123 |
+
if self.issues:
|
| 124 |
+
lines.append("Issues:")
|
| 125 |
+
lines.extend(f" • {i}" for i in self.issues)
|
| 126 |
+
if self.warnings:
|
| 127 |
+
lines.append("Warnings:")
|
| 128 |
+
lines.extend(f" ⚠ {w}" for w in self.warnings)
|
| 129 |
+
if self.suggestions:
|
| 130 |
+
lines.append("Suggestions:")
|
| 131 |
+
lines.extend(f" → {s}" for s in self.suggestions)
|
| 132 |
+
return "\n".join(lines)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
# Validator
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class ClinicalValidator:
|
| 141 |
+
"""
|
| 142 |
+
Multi-stage clinical validation pipeline for radiology reports.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
WEIGHTS = {
|
| 146 |
+
"completeness": 0.30,
|
| 147 |
+
"content_length": 0.15,
|
| 148 |
+
"terminology": 0.25,
|
| 149 |
+
"logical_consistency": 0.20,
|
| 150 |
+
"recommendation_quality": 0.10,
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
def validate(self, report) -> ValidationResult:
|
| 154 |
+
result = ValidationResult()
|
| 155 |
+
scores: Dict[str, float] = {}
|
| 156 |
+
|
| 157 |
+
sections = {
|
| 158 |
+
"examination_details": report.examination_details or "",
|
| 159 |
+
"clinical_information": report.clinical_information or "",
|
| 160 |
+
"findings": report.findings or "",
|
| 161 |
+
"impression": report.impression or "",
|
| 162 |
+
"recommendations": report.recommendations or "",
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
scores["completeness"] = self._check_completeness(sections, result)
|
| 166 |
+
scores["content_length"] = self._check_content_length(sections, result)
|
| 167 |
+
scores["terminology"] = self._check_terminology(sections, result)
|
| 168 |
+
|
| 169 |
+
# ✅ UPDATED LOGIC USED HERE
|
| 170 |
+
scores["logical_consistency"] = self._check_logical_consistency(sections, result)
|
| 171 |
+
|
| 172 |
+
scores["recommendation_quality"] = self._check_recommendations(sections, result)
|
| 173 |
+
|
| 174 |
+
weighted = sum(scores[k] * self.WEIGHTS[k] for k in self.WEIGHTS)
|
| 175 |
+
|
| 176 |
+
result.confidence_score = round(min(max(weighted, 0.30), 0.88), 3)
|
| 177 |
+
result.section_scores = scores
|
| 178 |
+
|
| 179 |
+
result.is_valid = (
|
| 180 |
+
result.confidence_score >= 0.55
|
| 181 |
+
and len(result.issues) == 0
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
return result
|
| 185 |
+
|
| 186 |
+
# ------------------------------------------------------------------
|
| 187 |
+
|
| 188 |
+
def _check_completeness(self, sections: Dict[str, str], result: ValidationResult) -> float:
|
| 189 |
+
missing = []
|
| 190 |
+
for sec in REQUIRED_SECTIONS:
|
| 191 |
+
if not sections.get(sec, "").strip():
|
| 192 |
+
missing.append(sec)
|
| 193 |
+
|
| 194 |
+
if missing:
|
| 195 |
+
result.issues.append(
|
| 196 |
+
f"Missing required sections: {', '.join(missing)}"
|
| 197 |
+
)
|
| 198 |
+
return 1.0 - (len(missing) / len(REQUIRED_SECTIONS))
|
| 199 |
+
|
| 200 |
+
return 1.0
|
| 201 |
+
|
| 202 |
+
# ------------------------------------------------------------------
|
| 203 |
+
|
| 204 |
+
def _check_content_length(self, sections: Dict[str, str], result: ValidationResult) -> float:
|
| 205 |
+
total, passing = 0, 0
|
| 206 |
+
for sec, min_words in MINIMUM_WORDS.items():
|
| 207 |
+
total += 1
|
| 208 |
+
word_count = len(sections.get(sec, "").split())
|
| 209 |
+
if word_count >= min_words:
|
| 210 |
+
passing += 1
|
| 211 |
+
else:
|
| 212 |
+
result.warnings.append(
|
| 213 |
+
f"Section '{sec}' is brief ({word_count} words; minimum recommended: {min_words})."
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
return passing / total if total > 0 else 0.0
|
| 217 |
+
|
| 218 |
+
# ------------------------------------------------------------------
|
| 219 |
+
|
| 220 |
+
def _check_terminology(self, sections: Dict[str, str], result: ValidationResult) -> float:
|
| 221 |
+
findings_text = sections.get("findings", "").lower()
|
| 222 |
+
impression_text = sections.get("impression", "").lower()
|
| 223 |
+
|
| 224 |
+
combined = findings_text + " " + impression_text
|
| 225 |
+
|
| 226 |
+
matched_terms = FINDING_TERMS.intersection(
|
| 227 |
+
set(re.findall(r"\b\w+\b", combined))
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
for term in FINDING_TERMS:
|
| 231 |
+
if " " in term and term in combined:
|
| 232 |
+
matched_terms.add(term)
|
| 233 |
+
|
| 234 |
+
score = min(len(matched_terms) / 2.0, 1.0)
|
| 235 |
+
|
| 236 |
+
if len(matched_terms) == 0:
|
| 237 |
+
result.issues.append(
|
| 238 |
+
"No recognised radiological terminology found in findings/impression."
|
| 239 |
+
)
|
| 240 |
+
elif len(matched_terms) < 2:
|
| 241 |
+
result.warnings.append(
|
| 242 |
+
"Limited radiological terminology detected. Report may lack specificity."
|
| 243 |
+
)
|
| 244 |
+
else:
|
| 245 |
+
result.suggestions.append(
|
| 246 |
+
f"Detected radiological terms: {', '.join(sorted(matched_terms)[:6])}."
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
return score
|
| 250 |
+
|
| 251 |
+
# ------------------------------------------------------------------
|
| 252 |
+
# ✅ FIXED FUNCTION (MAIN CHANGE)
|
| 253 |
+
# ------------------------------------------------------------------
|
| 254 |
+
|
| 255 |
+
def _check_logical_consistency(
|
| 256 |
+
self, sections: Dict[str, str], result: ValidationResult
|
| 257 |
+
) -> float:
|
| 258 |
+
|
| 259 |
+
findings = sections.get("findings", "").lower()
|
| 260 |
+
impression = sections.get("impression", "").lower()
|
| 261 |
+
|
| 262 |
+
if not findings or not impression:
|
| 263 |
+
return 0.5
|
| 264 |
+
|
| 265 |
+
score = 1.0
|
| 266 |
+
|
| 267 |
+
has_conclusion = any(
|
| 268 |
+
term in impression for term in IMPRESSION_CONCLUSION_TERMS
|
| 269 |
+
)
|
| 270 |
+
if not has_conclusion:
|
| 271 |
+
result.warnings.append(
|
| 272 |
+
"Impression does not contain a clear diagnostic conclusion."
|
| 273 |
+
)
|
| 274 |
+
score -= 0.3
|
| 275 |
+
|
| 276 |
+
# ✅ NEW CLEAN LOGIC
|
| 277 |
+
ABNORMAL_TERMS = {
|
| 278 |
+
"opacity", "opacities", "ground glass", "consolidation",
|
| 279 |
+
"lesion", "mass", "nodule", "effusion", "infiltrate",
|
| 280 |
+
"atelectasis", "fibrosis", "infection"
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
NORMAL_TERMS = {
|
| 284 |
+
"normal", "unremarkable", "no acute", "within normal limits",
|
| 285 |
+
"clear", "intact", "preserved"
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
findings_has_pathology = any(t in findings for t in ABNORMAL_TERMS)
|
| 289 |
+
impression_says_normal = any(t in impression for t in NORMAL_TERMS)
|
| 290 |
+
|
| 291 |
+
# 🚨 STRONG ERROR DETECTION
|
| 292 |
+
if findings_has_pathology and impression_says_normal:
|
| 293 |
+
result.issues.append(
|
| 294 |
+
"Critical error: Findings indicate abnormal pathology but impression states normal."
|
| 295 |
+
)
|
| 296 |
+
result.is_valid = False
|
| 297 |
+
score -= 0.7
|
| 298 |
+
|
| 299 |
+
# ⚠ Mixed statement detection
|
| 300 |
+
if "no" in findings and findings_has_pathology:
|
| 301 |
+
result.warnings.append(
|
| 302 |
+
"Findings contain both abnormal and negative descriptors; verify interpretation."
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
return max(score, 0.0)
|
| 306 |
+
|
| 307 |
+
# ------------------------------------------------------------------
|
| 308 |
+
|
| 309 |
+
def _check_recommendations(self, sections: Dict[str, str], result: ValidationResult) -> float:
|
| 310 |
+
recs = sections.get("recommendations", "").lower()
|
| 311 |
+
|
| 312 |
+
if not recs:
|
| 313 |
+
result.warnings.append("Recommendations section is empty.")
|
| 314 |
+
return 0.0
|
| 315 |
+
|
| 316 |
+
matched = RECOMMENDATION_TERMS.intersection(
|
| 317 |
+
set(re.findall(r"\b\w+\b", recs))
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
for term in RECOMMENDATION_TERMS:
|
| 321 |
+
if " " in term and term in recs:
|
| 322 |
+
matched.add(term)
|
| 323 |
+
|
| 324 |
+
score = min(len(matched) / 2.0, 1.0)
|
| 325 |
+
|
| 326 |
+
if len(matched) == 0:
|
| 327 |
+
result.warnings.append(
|
| 328 |
+
"Recommendations section does not contain specific clinical guidance."
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
return score
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
# ---------------------------------------------------------------------------
|
| 335 |
+
# Convenience Function
|
| 336 |
+
# ---------------------------------------------------------------------------
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def validate_report(report) -> ValidationResult:
|
| 340 |
+
"""Top-level convenience function."""
|
| 341 |
+
validator = ClinicalValidator()
|
| 342 |
+
return validator.validate(report)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
# ---------------------------------------------------------------------------
|
| 346 |
+
# Smoke Test
|
| 347 |
+
# ---------------------------------------------------------------------------
|
| 348 |
+
|
| 349 |
+
if __name__ == "__main__":
|
| 350 |
+
import sys
|
| 351 |
+
sys.path.insert(0, ".")
|
| 352 |
+
from backend.report_generator import PatientInfo, ImagingInfo, RadiologyReport
|
| 353 |
+
|
| 354 |
+
mock_report = RadiologyReport(
|
| 355 |
+
examination_details="Chest X-ray, posteroanterior view.",
|
| 356 |
+
clinical_information="52-year-old male presenting with persistent cough and fever for 3 days.",
|
| 357 |
+
findings=(
|
| 358 |
+
"Patchy consolidation in the right lower lobe with air bronchograms. "
|
| 359 |
+
"No pleural effusion. Cardiac silhouette within normal limits. "
|
| 360 |
+
"Costophrenic angles are sharp bilaterally."
|
| 361 |
+
),
|
| 362 |
+
impression=(
|
| 363 |
+
"Findings are consistent with right lower lobe pneumonia. "
|
| 364 |
+
"No evidence of pleural complication."
|
| 365 |
+
),
|
| 366 |
+
recommendations=(
|
| 367 |
+
"Clinical correlation recommended. "
|
| 368 |
+
"Consider repeat chest X-ray in 4 to 6 weeks to confirm resolution. "
|
| 369 |
+
"If symptoms persist, CT chest may be warranted."
|
| 370 |
+
),
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
result = validate_report(mock_report)
|
| 374 |
+
print(result.summary())
|
| 375 |
+
print(f"\nFull JSON: {result.to_dict()}")
|