Prakhar54-byte commited on
Commit
50d6286
Β·
verified Β·
1 Parent(s): c966ef1

Deploy build-f230daf

Browse files
Files changed (8) hide show
  1. COMMANDS.md +70 -0
  2. ETHICS.md +117 -0
  3. PERSON_B_TASKS.md +184 -0
  4. backend/main.py +28 -2
  5. frontend/app.py +38 -0
  6. requirements.txt +2 -0
  7. tests/__init__.py +0 -0
  8. tests/test_api.py +242 -0
COMMANDS.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PneumoOps: Operations & Deployment Runbook
2
+
3
+ This document contains all the essential commands for training, monitoring, deploying, and testing the PneumoOps pipeline.
4
+
5
+ ---
6
+
7
+ ## 1. Local Training & Monitoring
8
+
9
+ Because the model is training in the background using `nohup` (No Hangup), **you can safely close your laptop or disconnect from the server.** The training process is detached from your local session and will continue running strictly on the server until it finishes 15 epochs.
10
+
11
+ **To check the live training progress at any time:**
12
+ ```bash
13
+ tail -f training.log
14
+ ```
15
+ *(Press `Ctrl+C` to exit the live view. The training will continue running behind the scenes.)*
16
+
17
+ **If you need to manually stop the background training:**
18
+ ```bash
19
+ pkill -f train_chestmnist.py
20
+ ```
21
+
22
+ ---
23
+
24
+ ## 2. Docker Deployment (With Cybersecurity Patches)
25
+
26
+ The `Dockerfile` has been hardened to drop root privileges (`appuser`) and automatically fetch the newest OS security patches upon building.
27
+
28
+ **To build the secure container image:**
29
+ ```bash
30
+ docker compose build
31
+ ```
32
+
33
+ **To start the full stack (FastAPI Backend + Gradio Frontend) securely in the background:**
34
+ ```bash
35
+ docker compose up -d
36
+ ```
37
+
38
+ **To read the live Docker logs (useful for verifying the FastAPI startup):**
39
+ ```bash
40
+ docker compose logs -f
41
+ ```
42
+
43
+ *(Note: Because of GitHub Actions, pushing to the `master` branch will automatically run this build and deploy the containers to your Hugging Face space!)*
44
+
45
+ ---
46
+
47
+ ## 3. Testing the Live Deployment
48
+
49
+ Once the Docker containers are running (locally or on Hugging Face), you can probe them to test both Data Science metrics and DevOps/Platform performance.
50
+
51
+ ### A. Testing API Health & Model Statistics
52
+ Check what profiles and models the backend is serving, and view the embedded Brier scores.
53
+ ```bash
54
+ curl -s http://127.0.0.1:7860/health | jq
55
+ ```
56
+
57
+ ### B. Triggering a Prediction (A/B Test + Drift Monitor)
58
+ Pass a Chest X-ray image to the pipeline to see the A/B test router in action. The response will explicitly return the `drift_status` ("NORMAL" or "DRIFT_DETECTED") depending on statistical distribution checks.
59
+ ```bash
60
+ curl -X POST -F "file=@Screenshot_or_Xray.png" http://127.0.0.1:7860/predict
61
+ ```
62
+
63
+ ### C. Scraping Platform DevOps Metrics
64
+ Standard enterprise observability. Pull the raw Prometheus text logs to visualize live deployment performance.
65
+ ```bash
66
+ curl -s http://127.0.0.1:7860/metrics | grep "pneumoops"
67
+ ```
68
+ You should actively look for:
69
+ * `pneumoops_inference_latency_ms`: To compare PyTorch baseline speed vs ONNX optimizations.
70
+ * `pneumoops_disease_predictions_total`: To see which of the 14 multi-label diseases are most frequently diagnosed.
ETHICS.md ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ⚠️ ETHICS.md β€” PneumoOps Responsible AI Policy
2
+
3
+ > **This document is a mandatory part of the PneumoOps project and must be read before using or contributing to this system.**
4
+
5
+ ---
6
+
7
+ ## 🚨 1. NOT A Medical Device
8
+
9
+ > ❌ This tool does **NOT** detect disease with medical certainty.
10
+ > βœ… This tool **assists in prediction** as an educational MLOps demonstration.
11
+
12
+ PneumoOps is built for **academic and research purposes only**. It is a demonstration of MLOps concepts β€” A/B testing, drift monitoring, and continuous deployment β€” using chest X-ray classification as an applied example.
13
+
14
+ **This system is NOT:**
15
+ - A licensed medical device
16
+ - A substitute for professional clinical diagnosis
17
+ - Validated for use in patient care
18
+
19
+ **Every prediction must be treated as:**
20
+ - Potentially incorrect
21
+ - Requiring review by a qualified medical professional
22
+ - An educational output, not a clinical recommendation
23
+
24
+ ---
25
+
26
+ ## πŸ” 2. Data Privacy & Consent
27
+
28
+ ### What This System Does With Your Images
29
+ - Images uploaded to this tool are processed **in-memory** for inference only
30
+ - **No images are permanently stored** on the server by default
31
+ - **No user-identifiable information** (name, date of birth, patient ID, scan metadata) is collected, logged, or retained
32
+ - Prometheus metrics log **aggregate statistics only** (prediction class counts, latency) β€” never individual images or identities
33
+
34
+ ### What You Must NOT Upload
35
+ - X-rays containing embedded patient metadata (DICOM tags)
36
+ - Images with visible patient names, dates, or hospital identifiers
37
+ - Any image you do not have the right to use
38
+
39
+ ### If Data Collection Is Enabled (Optional Fine-Tuning Mode)
40
+ If the `PNEUMOOPS_COLLECT_DATA=true` environment variable is set:
41
+ - A text-based **consent notice** is shown to the user before submission
42
+ - Only the **anonymized image** (stripped of metadata) and its **prediction JSON** are saved
43
+ - Data is stored locally and never transmitted to third parties
44
+ - Users can opt out by not submitting their image
45
+
46
+ ---
47
+
48
+ ## βš–οΈ 3. Bias & Accuracy Limitations
49
+
50
+ This model was trained on **ChestMNIST**, a research dataset with known limitations:
51
+
52
+ | Limitation | Risk |
53
+ |---|---|
54
+ | Small image size (224Γ—224, grayscale) | May miss subtle findings visible on full-resolution clinical scans |
55
+ | Dataset demographic bias | Performance may vary across different patient populations, scanner hardware, and imaging protocols |
56
+ | Class imbalance | Rare conditions (Hernia, Pneumonia) have very low F1 scores (0.0) due to insufficient training examples |
57
+ | No temporal data | The model sees single frames only β€” cannot account for disease progression |
58
+ | No radiologist validation | Predictions have NOT been validated by clinical experts |
59
+
60
+ **Macro AUROC of 0.808** is a research-grade metric. It does **not** translate to clinical accuracy, sensitivity, or specificity at a diagnostic threshold.
61
+
62
+ ---
63
+
64
+ ## 🧠 4. Responsible AI Language Policy
65
+
66
+ All communication from this system must follow these rules:
67
+
68
+ ### βœ… Use This Language
69
+ - "The model predicts a possible finding of..."
70
+ - "This assists in identifying potential..."
71
+ - "Confidence score indicates a statistical likelihood of..."
72
+ - "Results should be reviewed by a qualified clinician."
73
+
74
+ ### ❌ Never Use This Language
75
+ - "This patient has pneumonia."
76
+ - "The model detects disease with X% accuracy."
77
+ - "This result confirms a diagnosis of..."
78
+ - "No disease found." (absence of prediction β‰  absence of disease)
79
+
80
+ ---
81
+
82
+ ## πŸ”„ 5. Model Drift & Retraining Obligations
83
+
84
+ The drift monitoring system exists for a reason. When `DRIFT_DETECTED` is flagged:
85
+ - The incoming image is **statistically different** from training data
86
+ - Predictions on out-of-distribution data are **unreliable**
87
+ - A human reviewer should flag this image
88
+ - Retraining should be considered if drift is systematic
89
+
90
+ Ignoring persistent drift alerts in a production clinical system would be an **ethical failure**.
91
+
92
+ ---
93
+
94
+ ## πŸ‘₯ 6. Attribution & Accountability
95
+
96
+ | Role | Responsibility |
97
+ |---|---|
98
+ | Developers | Ensure model limitations are clearly communicated |
99
+ | Operators | Never deploy without visible disclaimers |
100
+ | Users | Never use predictions to make unsupervised clinical decisions |
101
+ | Evaluators | Judge on MLOps pipeline quality, not clinical validity |
102
+
103
+ ---
104
+
105
+ ## πŸ“‹ 7. Compliance Acknowledgment
106
+
107
+ By using, deploying, or contributing to PneumoOps, you acknowledge that:
108
+
109
+ - [ ] You have read this document in full
110
+ - [ ] You understand this is an educational tool, not a medical device
111
+ - [ ] You will not use predictions as a substitute for clinical judgment
112
+ - [ ] You will not upload images containing patient PII
113
+ - [ ] You accept responsibility for any use of this system
114
+
115
+ ---
116
+
117
+ *This ethics policy was authored as part of the PneumoOps academic MLOps project. It follows principles from the [EU AI Act](https://www.europarl.europa.eu/topics/en/article/20230601STO93804/eu-ai-act-first-regulation-on-artificial-intelligence), [WHO Ethics Guidelines for AI in Health](https://www.who.int/publications/i/item/9789240029200), and [Google's Responsible AI Practices](https://ai.google/responsibility/responsible-ai-practices/).*
PERSON_B_TASKS.md ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # πŸ‘©β€πŸ’» Person B β€” Standalone Task Guide
2
+ ## PneumoOps: Backend Verification, Model Registry & Monitoring
3
+
4
+ > **This guide is self-contained.** You only need the GitHub repository link and the HF Token that Person A will share with you privately. You do NOT need access to Person A's machine.
5
+
6
+ ---
7
+
8
+ ## πŸ”‘ What You Need From Person A (Ask Before Starting)
9
+
10
+ | Item | How to Get It |
11
+ |---|---|
12
+ | GitHub repository URL | `https://github.com/Prakhar54-byte/PneumoOps` |
13
+ | Hugging Face Access Token | Person A will share privately (WhatsApp/DM) β€” do NOT share publicly |
14
+ | HF Model Repo name | `Prakhar54-byte/pneumoops-chestmnist` (or whatever Person A created) |
15
+
16
+ ---
17
+
18
+ ## βš™οΈ Step 0: One-Time Environment Setup
19
+
20
+ Open a terminal on your machine and run these commands:
21
+
22
+ ```bash
23
+ # 1. Clone the GitHub repository
24
+ git clone https://github.com/Prakhar54-byte/PneumoOps.git
25
+ cd PneumoOps
26
+
27
+ # 2. Create a Python virtual environment
28
+ python3 -m venv .venv
29
+ source .venv/bin/activate # On Windows: .venv\Scripts\activate
30
+
31
+ # 3. Install all dependencies (CPU-only torch is fine for testing)
32
+ pip install -r requirements.txt
33
+
34
+ # 4. Login to Hugging Face using the token Person A gave you
35
+ pip install huggingface_hub
36
+ huggingface-cli login
37
+ # β†’ Paste the token when prompted. Press Enter.
38
+ ```
39
+
40
+ ---
41
+
42
+ ## πŸ“¦ Task B1: Download the Model Files From HF Hub
43
+
44
+ > The `.pth` and `.onnx` model files are too large for Git. They live on Hugging Face Model Hub.
45
+
46
+ ```bash
47
+ # Set the model repo (ask Person A for the exact name)
48
+ export HF_MODEL_REPO="Prakhar54-byte/pneumoops-chestmnist"
49
+
50
+ # Download all model artifacts to the correct local folder
51
+ python3 - <<'PY'
52
+ from huggingface_hub import snapshot_download
53
+ import shutil, os
54
+
55
+ local_dir = snapshot_download(
56
+ repo_id=os.environ["HF_MODEL_REPO"],
57
+ repo_type="model",
58
+ local_dir="models/chestmnist_mobilenetv3",
59
+ ignore_patterns=["*.md"],
60
+ )
61
+ print(f"βœ… Downloaded to: {local_dir}")
62
+ PY
63
+ ```
64
+
65
+ After this runs, confirm the files exist:
66
+ ```bash
67
+ ls models/chestmnist_mobilenetv3/
68
+ # Expected: mobilenetv3_chestmnist.pth mobilenetv3_chestmnist.onnx
69
+ # training_metrics.json baseline_stats.json onnx_export_report.json
70
+ ```
71
+
72
+ ---
73
+
74
+ ## 🐳 Task B2: Start the App Locally With Docker
75
+
76
+ ```bash
77
+ # Make sure Docker Desktop is installed and running first
78
+ docker compose up --build -d app
79
+
80
+ # Wait ~30 seconds for startup, then check health
81
+ curl http://127.0.0.1:7860/health
82
+ ```
83
+
84
+ Expected output (both models should show `true`):
85
+ ```json
86
+ {
87
+ "status": "ok",
88
+ "pytorch_model_loaded": true,
89
+ "onnx_model_loaded": true,
90
+ "class_count": 14
91
+ }
92
+ ```
93
+
94
+ ---
95
+
96
+ ## πŸ§ͺ Task B3: Run the Automated Tests
97
+
98
+ ```bash
99
+ # Run all tests with verbose output
100
+ python -m pytest tests/ -v
101
+
102
+ # Expected output should show all PASSED:
103
+ # tests/test_api.py::test_health_check PASSED
104
+ # tests/test_api.py::test_metrics_endpoint PASSED
105
+ # tests/test_api.py::test_predict_with_synthetic_image PASSED
106
+ # tests/test_api.py::test_drift_on_non_xray PASSED
107
+ ```
108
+
109
+
110
+ ---
111
+
112
+ ## πŸ–₯️ Task B4: Verify the Gradio UI
113
+
114
+ 1. Open your browser and go to: **`http://localhost:7860/ui`**
115
+ 2. Upload any image (a chest X-ray from Google Images is fine for testing)
116
+ 3. Click **"Run Screening"**
117
+ 4. Verify that ALL five output fields are displayed:
118
+ - βœ… Top-3 Predictions bar chart
119
+ - βœ… Model Used (shows "Baseline PyTorch" OR "Optimized ONNX")
120
+ - βœ… Inference Latency (e.g., "12.3 ms")
121
+ - βœ… All Findings Detected
122
+ - βœ… Data Drift Alert (green "NORMAL" for a real X-ray)
123
+ 5. Upload a non-X-ray (e.g., a photo of a dog or landscape)
124
+ - βœ… You should see a red **"⚠️ DRIFT DETECTED"** badge
125
+
126
+ > **Take a screenshot of both tests (normal + drift)** β€” these are needed for the poster and final report.
127
+
128
+ ---
129
+
130
+ ## πŸ“Š Task B5: Production Monitoring
131
+
132
+ These endpoints let you observe how the model is performing without looking at individual images:
133
+
134
+ ```bash
135
+ # Recent request history (last 20 predictions)
136
+ curl http://127.0.0.1:7860/history | python3 -m json.tool
137
+
138
+ # Per-class prediction rates
139
+ # (which diseases are predicted most often)
140
+ curl http://127.0.0.1:7860/metrics/class-rates | python3 -m json.tool
141
+
142
+ # AUROC / AUPRC per disease class (from training)
143
+ curl http://127.0.0.1:7860/metrics/calibration | python3 -m json.tool
144
+
145
+ # Prometheus metrics (raw counters/histograms for monitoring tools)
146
+ curl http://127.0.0.1:7860/metrics | grep pneumoops
147
+ ```
148
+
149
+ > The Prometheus `/metrics` endpoint can be connected to **Grafana** in a real production setup for dashboards. For this project, reading the raw text output is sufficient.
150
+
151
+ ---
152
+
153
+ ## ✍️ Task B6: Documentation (Poster & Report)
154
+
155
+ - [ ] Copy the text from `POSTER_CONTENT.md` into the Canva poster template
156
+ - [ ] Read `ETHICS.md` β€” you may need to explain the ethical considerations in your presentation
157
+ - [ ] Update the `README.md` with your name in the contributors section:
158
+ ```bash
159
+ # In the README, find "Contributors" and add your name
160
+ git add README.md
161
+ git commit -m "docs: add contributors section"
162
+ git push
163
+ ```
164
+
165
+ ---
166
+
167
+ ## 🚨 Troubleshooting
168
+
169
+ | Problem | Fix |
170
+ |---|---|
171
+ | `docker: command not found` | Install Docker Desktop from docker.com |
172
+ | `models/*.pth not found` | Run Task B1 again β€” the download may have failed |
173
+ | `curl: (7) Failed to connect` | The Docker container isn't running. Run `docker compose up -d app` |
174
+ | `pytest: command not found` | Run `pip install pytest httpx` inside your virtual environment |
175
+ | Test `test_predict` fails | Check `docker logs pneumo_ops-app-1` for error messages |
176
+
177
+ ---
178
+
179
+ ## πŸ“ž Contact
180
+
181
+ If you are stuck, message Person A with:
182
+ 1. The exact error message (copy-paste it)
183
+ 2. Which Task step you are on
184
+ 3. The output of `docker logs pneumo_ops-app-1`
backend/main.py CHANGED
@@ -6,6 +6,7 @@ import os
6
  import random
7
  import sys
8
  import time
 
9
  from collections import deque
10
  from datetime import datetime, timezone
11
  from pathlib import Path
@@ -15,7 +16,7 @@ import gradio as gr
15
  import numpy as np
16
  import onnxruntime as ort
17
  import torch
18
- from fastapi import FastAPI, File, HTTPException, Request, Response, UploadFile
19
  from fastapi.middleware.cors import CORSMiddleware
20
  from fastapi.responses import PlainTextResponse
21
  from PIL import Image
@@ -43,6 +44,8 @@ REQUEST_LOG_HISTORY = deque(maxlen=20)
43
  API_KEY = os.getenv("PNEUMOOPS_API_KEY")
44
  ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("PNEUMOOPS_ALLOWED_ORIGINS", "*").split(",") if origin.strip()]
45
  TRAFFIC_WEIGHTS = {"pytorch": 60, "onnx": 40}
 
 
46
  LOW_CONFIDENCE_THRESHOLD = float(os.getenv("PNEUMOOPS_LOW_CONFIDENCE_THRESHOLD", "0.60"))
47
  MIN_UPLOAD_EDGE = int(os.getenv("PNEUMOOPS_MIN_UPLOAD_EDGE", "96"))
48
  MAX_CHANNEL_DELTA = float(os.getenv("PNEUMOOPS_MAX_CHANNEL_DELTA", "0.08"))
@@ -473,6 +476,25 @@ def emit_structured_log(payload: dict[str, Any]) -> None:
473
  logger.info(json.dumps(payload, ensure_ascii=True))
474
 
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  @app.get("/health")
477
  def health():
478
  return {
@@ -553,7 +575,7 @@ def calibration_summary():
553
 
554
 
555
  @app.post("/predict")
556
- async def predict(request: Request, file: UploadFile = File(...)):
557
  image = load_image_from_upload(file)
558
  input_summary = summarize_image(image)
559
  validate_image(image, input_summary)
@@ -643,6 +665,10 @@ async def predict(request: Request, file: UploadFile = File(...)):
643
  )
644
 
645
  response_payload["request_latency_ms"] = round((time.perf_counter() - start) * 1000, 2)
 
 
 
 
646
  return response_payload
647
 
648
 
 
6
  import random
7
  import sys
8
  import time
9
+ import uuid
10
  from collections import deque
11
  from datetime import datetime, timezone
12
  from pathlib import Path
 
16
  import numpy as np
17
  import onnxruntime as ort
18
  import torch
19
+ from fastapi import BackgroundTasks, FastAPI, File, HTTPException, Request, Response, UploadFile
20
  from fastapi.middleware.cors import CORSMiddleware
21
  from fastapi.responses import PlainTextResponse
22
  from PIL import Image
 
44
  API_KEY = os.getenv("PNEUMOOPS_API_KEY")
45
  ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("PNEUMOOPS_ALLOWED_ORIGINS", "*").split(",") if origin.strip()]
46
  TRAFFIC_WEIGHTS = {"pytorch": 60, "onnx": 40}
47
+ COLLECT_DATA = os.getenv("PNEUMOOPS_COLLECT_DATA", "true").lower() == "true"
48
+ COLLECT_DIR = BASE_DIR / "data" / "collected_images"
49
  LOW_CONFIDENCE_THRESHOLD = float(os.getenv("PNEUMOOPS_LOW_CONFIDENCE_THRESHOLD", "0.60"))
50
  MIN_UPLOAD_EDGE = int(os.getenv("PNEUMOOPS_MIN_UPLOAD_EDGE", "96"))
51
  MAX_CHANNEL_DELTA = float(os.getenv("PNEUMOOPS_MAX_CHANNEL_DELTA", "0.08"))
 
476
  logger.info(json.dumps(payload, ensure_ascii=True))
477
 
478
 
479
+ def save_prediction_data(image: Image.Image, payload: dict[str, Any]) -> None:
480
+ """Save anonymized image and prediction data for model fine-tuning."""
481
+ if not COLLECT_DATA:
482
+ return
483
+ try:
484
+ COLLECT_DIR.mkdir(parents=True, exist_ok=True)
485
+ record_id = str(uuid.uuid4())
486
+
487
+ # Save image
488
+ img_path = COLLECT_DIR / f"{record_id}.png"
489
+ image.save(img_path, format="PNG")
490
+
491
+ # Save prediction payload
492
+ json_path = COLLECT_DIR / f"{record_id}.json"
493
+ json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
494
+ except Exception as e:
495
+ logger.error(f"Failed to save collection data: {e}")
496
+
497
+
498
  @app.get("/health")
499
  def health():
500
  return {
 
575
 
576
 
577
  @app.post("/predict")
578
+ async def predict(request: Request, background_tasks: BackgroundTasks, file: UploadFile = File(...)):
579
  image = load_image_from_upload(file)
580
  input_summary = summarize_image(image)
581
  validate_image(image, input_summary)
 
665
  )
666
 
667
  response_payload["request_latency_ms"] = round((time.perf_counter() - start) * 1000, 2)
668
+
669
+ # Trigger background save for fine-tuning
670
+ background_tasks.add_task(save_prediction_data, image, response_payload)
671
+
672
  return response_payload
673
 
674
 
frontend/app.py CHANGED
@@ -173,6 +173,26 @@ CSS = """
173
  }
174
  .hero h1 { margin: 0 0 0.4rem; font-size: 2rem; letter-spacing: -1px; }
175
  .hero p { margin: 0; opacity: 0.85; font-size: 0.95rem; line-height: 1.6; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  """
177
 
178
  with gr.Blocks(
@@ -181,6 +201,19 @@ with gr.Blocks(
181
  title="PneumoOps β€” A/B Testing MLOps Pipeline",
182
  ) as demo:
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  gr.HTML("""
185
  <div class="hero">
186
  <h1>🫁 PneumoOps</h1>
@@ -220,6 +253,11 @@ with gr.Blocks(
220
  Pleural Thickening Β· Hernia
221
 
222
  πŸ”΄ Critical Β· 🟠 Significant Β· 🟣 Standard
 
 
 
 
 
223
  """)
224
 
225
  submit_btn.click(
 
173
  }
174
  .hero h1 { margin: 0 0 0.4rem; font-size: 2rem; letter-spacing: -1px; }
175
  .hero p { margin: 0; opacity: 0.85; font-size: 0.95rem; line-height: 1.6; }
176
+ .ethics-banner {
177
+ background: #fff7ed;
178
+ border: 2px solid #fb923c;
179
+ border-radius: 12px;
180
+ padding: 1rem 1.4rem;
181
+ margin-bottom: 1rem;
182
+ font-size: 0.88rem;
183
+ line-height: 1.6;
184
+ color: #7c2d12;
185
+ }
186
+ .ethics-banner strong { color: #c2410c; }
187
+ .privacy-note {
188
+ background: #eff6ff;
189
+ border-left: 4px solid #3b82f6;
190
+ border-radius: 6px;
191
+ padding: 0.6rem 1rem;
192
+ margin-bottom: 0.5rem;
193
+ font-size: 0.82rem;
194
+ color: #1e3a5f;
195
+ }
196
  """
197
 
198
  with gr.Blocks(
 
201
  title="PneumoOps β€” A/B Testing MLOps Pipeline",
202
  ) as demo:
203
 
204
+ gr.HTML("""
205
+ <div class="ethics-banner">
206
+ <strong>⚠️ IMPORTANT DISCLAIMER β€” READ BEFORE USE</strong><br/>
207
+ This tool is for <strong>educational and research purposes ONLY</strong> and is <strong>NOT a medical device</strong>.<br/>
208
+ Predictions <strong>must not be used</strong> for clinical diagnosis, patient care, or any medical decision-making.<br/>
209
+ Always consult a qualified healthcare professional. Model performance may vary across demographics and scan quality.
210
+ </div>
211
+ <div class="privacy-note">
212
+ πŸ” <strong>Data Privacy:</strong> Uploaded images are processed in-memory only. No personally identifiable information is stored.
213
+ Do <u>not</u> upload images containing patient names, IDs, or other identifying metadata.
214
+ </div>
215
+ """)
216
+
217
  gr.HTML("""
218
  <div class="hero">
219
  <h1>🫁 PneumoOps</h1>
 
253
  Pleural Thickening Β· Hernia
254
 
255
  πŸ”΄ Critical Β· 🟠 Significant Β· 🟣 Standard
256
+
257
+ ---
258
+ > βš–οΈ **Responsible AI Notice:** This system *assists in prediction*, it does not confirm diagnoses.
259
+ > Results are statistical estimates and must be reviewed by a qualified clinician.
260
+ > See [ETHICS.md](https://github.com/Prakhar54-byte/PneumoOps/blob/master/ETHICS.md) for our full Responsible AI policy.
261
  """)
262
 
263
  submit_btn.click(
requirements.txt CHANGED
@@ -17,3 +17,5 @@ seaborn==0.13.2
17
  torch==2.6.0
18
  torchvision==0.21.0
19
  uvicorn[standard]==0.34.1
 
 
 
17
  torch==2.6.0
18
  torchvision==0.21.0
19
  uvicorn[standard]==0.34.1
20
+ pytest==8.3.5
21
+ httpx==0.28.1
tests/__init__.py ADDED
File without changes
tests/test_api.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PneumoOps β€” Automated Test Suite
3
+ ==================================
4
+ Tests the FastAPI backend endpoints without requiring real model files.
5
+ Uses synthetic images so the test suite runs on any machine (CI/CD included).
6
+
7
+ Run:
8
+ python -m pytest tests/ -v
9
+
10
+ Requirements:
11
+ pip install pytest httpx pillow numpy
12
+ """
13
+
14
+ import io
15
+ import json
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ import pytest
22
+ from PIL import Image
23
+
24
+ # ─── Make sure the project root is in the path ────────────────────────────────
25
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
26
+
27
+ # ─── Skip model-loading tests if model files are missing ──────────────────────
28
+ MODEL_DIR = Path(__file__).resolve().parents[1] / "models" / "chestmnist_mobilenetv3"
29
+ MODELS_AVAILABLE = (
30
+ (MODEL_DIR / "mobilenetv3_chestmnist.pth").exists()
31
+ and (MODEL_DIR / "mobilenetv3_chestmnist.onnx").exists()
32
+ )
33
+
34
+
35
+ # ─── Synthetic image helpers ──────────────────────────────────────────────────
36
+
37
+ def make_synthetic_xray(size: int = 224) -> bytes:
38
+ """Create a fake grayscale chest X-ray as PNG bytes."""
39
+ arr = np.random.normal(loc=0.35, scale=0.12, size=(size, size))
40
+ arr = np.clip(arr * 255, 0, 255).astype(np.uint8)
41
+ img = Image.fromarray(arr, mode="L").convert("RGB")
42
+ buf = io.BytesIO()
43
+ img.save(buf, format="PNG")
44
+ return buf.getvalue()
45
+
46
+
47
+ def make_color_image(size: int = 224) -> bytes:
48
+ """Create a colorful non-X-ray image to trigger drift detection."""
49
+ arr = np.zeros((size, size, 3), dtype=np.uint8)
50
+ arr[:, :, 0] = 200 # strong red channel
51
+ arr[:, :, 1] = 100 # green
52
+ arr[:, :, 2] = 50 # blue
53
+ img = Image.fromarray(arr, mode="RGB")
54
+ buf = io.BytesIO()
55
+ img.save(buf, format="PNG")
56
+ return buf.getvalue()
57
+
58
+
59
+ # ─── Live API tests (require running Docker container) ────────────────────────
60
+
61
+ @pytest.mark.live
62
+ class TestLiveAPI:
63
+ """
64
+ These tests hit the running Docker container.
65
+ Start it first: docker compose up -d app
66
+ Then run: python -m pytest tests/ -v -m live
67
+ """
68
+
69
+ BASE_URL = os.getenv("PNEUMOOPS_TEST_URL", "http://127.0.0.1:7860")
70
+
71
+ def test_health_check(self):
72
+ """Backend /health must return status=ok with both models loaded."""
73
+ import requests
74
+ resp = requests.get(f"{self.BASE_URL}/health", timeout=10)
75
+ assert resp.status_code == 200, f"Health check failed: {resp.text}"
76
+ data = resp.json()
77
+ assert data["status"] == "ok", "Status field is not 'ok'"
78
+ assert data["class_count"] == 14, "Expected 14 ChestMNIST classes"
79
+ print(f"\n βœ… Health OK β€” PyTorch:{data['pytorch_model_loaded']} ONNX:{data['onnx_model_loaded']}")
80
+
81
+ def test_metrics_endpoint(self):
82
+ """Prometheus /metrics endpoint must return text with pneumoops counters."""
83
+ import requests
84
+ resp = requests.get(f"{self.BASE_URL}/metrics", timeout=10)
85
+ assert resp.status_code == 200
86
+ assert "pneumoops_requests_total" in resp.text, "Counter metric missing"
87
+ assert "pneumoops_inference_latency_ms" in resp.text, "Latency histogram missing"
88
+ print("\n βœ… Prometheus metrics endpoint OK")
89
+
90
+ def test_predict_with_synthetic_xray(self):
91
+ """POST /predict with a synthetic X-ray must return valid prediction JSON."""
92
+ import requests
93
+ image_bytes = make_synthetic_xray()
94
+ resp = requests.post(
95
+ f"{self.BASE_URL}/predict",
96
+ files={"file": ("test_xray.png", image_bytes, "image/png")},
97
+ timeout=30,
98
+ )
99
+ assert resp.status_code == 200, f"Predict failed: {resp.text}"
100
+ data = resp.json()
101
+
102
+ # Required fields
103
+ assert "predicted_labels" in data, "Missing predicted_labels"
104
+ assert "top_predictions" in data, "Missing top_predictions"
105
+ assert "drift" in data, "Missing drift field"
106
+ assert "selected_arm" in data, "Missing selected_arm (A/B)"
107
+ assert data["selected_arm"] in ("A", "B"), f"Invalid arm: {data['selected_arm']}"
108
+
109
+ # Top predictions structure
110
+ for pred in data["top_predictions"]:
111
+ assert "label" in pred
112
+ assert "confidence" in pred
113
+ assert 0.0 <= pred["confidence"] <= 100.0
114
+
115
+ # Latency fields
116
+ assert "request_latency_ms" in data
117
+ assert data["request_latency_ms"] > 0
118
+
119
+ print(f"\n βœ… Predict OK β€” arm={data['selected_arm']} labels={data['predicted_labels']}")
120
+
121
+ def test_drift_detected_on_color_image(self):
122
+ """A strongly colored non-X-ray image should trigger drift detection."""
123
+ import requests
124
+ image_bytes = make_color_image()
125
+ # Color images fail the channel_delta validation check first (400),
126
+ # which is also correct behavior β€” the system rejects them before inference.
127
+ resp = requests.post(
128
+ f"{self.BASE_URL}/predict",
129
+ files={"file": ("color.png", image_bytes, "image/png")},
130
+ timeout=30,
131
+ )
132
+ # Either rejected with 400 (color image guard) OR passes with DRIFT_DETECTED
133
+ if resp.status_code == 400:
134
+ print("\n βœ… Color image correctly rejected (channel_delta guard)")
135
+ else:
136
+ assert resp.status_code == 200
137
+ data = resp.json()
138
+ assert data["drift"]["drift_alert"] == "DRIFT_DETECTED", (
139
+ f"Expected DRIFT_DETECTED for color image, got: {data['drift']}"
140
+ )
141
+ print("\n βœ… Drift correctly detected on color image")
142
+
143
+ def test_history_endpoint(self):
144
+ """GET /history must return a list of recent requests."""
145
+ import requests
146
+ resp = requests.get(f"{self.BASE_URL}/history", timeout=10)
147
+ assert resp.status_code == 200
148
+ data = resp.json()
149
+ assert "recent_requests" in data
150
+ assert isinstance(data["recent_requests"], list)
151
+ print(f"\n βœ… History OK β€” {len(data['recent_requests'])} recent entries")
152
+
153
+ def test_class_rates_endpoint(self):
154
+ """GET /metrics/class-rates must return per-class rates for all 14 classes."""
155
+ import requests
156
+ resp = requests.get(f"{self.BASE_URL}/metrics/class-rates", timeout=10)
157
+ assert resp.status_code == 200
158
+ data = resp.json()
159
+ rates = data.get("per_class_prediction_rate", {})
160
+ expected_classes = {
161
+ "Atelectasis", "Cardiomegaly", "Effusion", "Infiltration",
162
+ "Mass", "Nodule", "Pneumonia", "Pneumothorax",
163
+ "Consolidation", "Edema", "Emphysema", "Fibrosis",
164
+ "Pleural_Thickening", "Hernia",
165
+ }
166
+ assert expected_classes.issubset(set(rates.keys())), (
167
+ f"Missing classes: {expected_classes - set(rates.keys())}"
168
+ )
169
+ print(f"\n βœ… Class-rates OK β€” {len(rates)} classes tracked")
170
+
171
+
172
+ # ─── Offline unit tests (no server needed) ────────────────────────────────────
173
+
174
+ class TestImageHelpers:
175
+ """Tests for standalone utility functions that need no server."""
176
+
177
+ def test_synthetic_xray_is_valid_png(self):
178
+ """Synthetic X-ray generator must produce a decodable image."""
179
+ raw = make_synthetic_xray()
180
+ img = Image.open(io.BytesIO(raw))
181
+ assert img.format == "PNG"
182
+ assert img.mode == "RGB"
183
+ assert img.size == (224, 224)
184
+
185
+ def test_synthetic_xray_is_grayscale_like(self):
186
+ """Synthetic X-ray channels should be very similar (low channel_delta)."""
187
+ raw = make_synthetic_xray()
188
+ img = Image.open(io.BytesIO(raw))
189
+ arr = np.array(img, dtype=np.float32) / 255.0
190
+ r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
191
+ # Because we generated from grayscale, R==G==B
192
+ np.testing.assert_array_equal(r, g)
193
+ np.testing.assert_array_equal(g, b)
194
+
195
+ def test_color_image_has_high_channel_delta(self):
196
+ """Color image generator must produce an image with high RGB channel variance."""
197
+ raw = make_color_image()
198
+ img = Image.open(io.BytesIO(raw))
199
+ arr = np.array(img, dtype=np.float32) / 255.0
200
+ r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
201
+ delta = float(
202
+ np.mean(np.abs(r - g)) + np.mean(np.abs(g - b)) + np.mean(np.abs(r - b))
203
+ ) / 3.0
204
+ assert delta > 0.08, f"Expected high channel delta for color image, got {delta:.4f}"
205
+
206
+ def test_ethics_file_exists(self):
207
+ """ETHICS.md must be present in the project root."""
208
+ ethics_path = Path(__file__).resolve().parents[1] / "ETHICS.md"
209
+ assert ethics_path.exists(), "ETHICS.md is missing from project root!"
210
+ content = ethics_path.read_text()
211
+ assert "NOT a Medical Device" in content or "NOT A Medical Device" in content or "not a medical device" in content.lower()
212
+ assert "Data Privacy" in content
213
+ assert "Bias" in content
214
+
215
+ def test_training_metrics_json_is_valid(self):
216
+ """training_metrics.json must exist and contain expected keys."""
217
+ metrics_path = MODEL_DIR / "training_metrics.json"
218
+ if not metrics_path.exists():
219
+ pytest.skip("Model files not downloaded β€” run Task B1 first.")
220
+ with open(metrics_path) as f:
221
+ metrics = json.load(f)
222
+ assert "class_names" in metrics
223
+ assert len(metrics["class_names"]) == 14, "Expected 14 ChestMNIST classes"
224
+ assert "test_macro_roc_auc" in metrics
225
+ assert 0.0 <= metrics["test_macro_roc_auc"] <= 1.0
226
+ print(f"\n βœ… Metrics valid β€” Macro AUROC: {metrics['test_macro_roc_auc']:.3f}")
227
+
228
+ def test_baseline_stats_json_is_valid(self):
229
+ """baseline_stats.json must exist and contain drift reference fields."""
230
+ stats_path = MODEL_DIR / "baseline_stats.json"
231
+ if not stats_path.exists():
232
+ pytest.skip("Model files not downloaded β€” run Task B1 first.")
233
+ with open(stats_path) as f:
234
+ stats = json.load(f)
235
+ # Support both key formats (old: pixel_mean_mean / new: pixel_mean)
236
+ has_mean = "pixel_mean_mean" in stats or "pixel_mean" in stats
237
+ has_std = "pixel_std_mean" in stats or "pixel_std" in stats
238
+ assert has_mean, f"Missing pixel mean key. Keys found: {list(stats.keys())}"
239
+ assert has_std, f"Missing pixel std key. Keys found: {list(stats.keys())}"
240
+ mean_val = stats.get("pixel_mean", stats.get("pixel_mean_mean", 0))
241
+ assert -2.0 <= mean_val <= 2.0, f"Unexpected pixel mean value: {mean_val}"
242
+ print(f"\n βœ… Baseline stats valid β€” pixel_mean={mean_val:.4f}")