Spaces:
Sleeping
Sleeping
simulated A/B testing
Browse files- backend/ml_model.py +67 -1
- backend/routers/predict.py +16 -6
backend/ml_model.py
CHANGED
|
@@ -58,6 +58,72 @@ except Exception as e:
|
|
| 58 |
|
| 59 |
|
| 60 |
# Image processing transformation pipeline
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
# Image processing transformation pipeline
|
| 61 |
+
transform_pipeline = transforms.Compose([
|
| 62 |
+
transforms.Resize((224, 224)),
|
| 63 |
+
transforms.ToTensor(),
|
| 64 |
+
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
| 65 |
+
])
|
| 66 |
|
| 67 |
+
def run_diagnostic_inference(image_bytes: bytes) -> dict:
|
| 68 |
+
"""
|
| 69 |
+
uns an image buffer through the DenseNet pipeline, generates diagnostic probabilities,
|
| 70 |
+
and constructs a visual Grad-CAM interpretability overlay.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
image_bytes (bytes): The input image in bytes format.
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
dict: A dictionary containing the predicted class, confidence score, and the Grad-CAM overlay image in bytes format.
|
| 77 |
+
"""
|
| 78 |
+
if model is None:
|
| 79 |
+
raise RuntimeError('Model is not loaded. Cannot perform inference.')
|
| 80 |
+
|
| 81 |
+
# Open image from raw stream byte and convert to standard RGB format
|
| 82 |
+
image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
|
| 83 |
+
# Generate tensor batch for PyTorch model execution
|
| 84 |
+
tensor_img = transform_pipeline(image).unsqueeze(0).to(DEVICE)
|
| 85 |
+
# Perform class inference
|
| 86 |
+
with torch.no_grad():
|
| 87 |
+
outputs = model(tensor_img)
|
| 88 |
+
probabilities = torch.softmax(outputs, dim=1).cpu().numpy()[0]
|
| 89 |
+
|
| 90 |
+
# Class 0: Normal | Class 1: Pneumonia
|
| 91 |
+
normal_prob = float(probabilities[0])
|
| 92 |
+
pneumonia_prob = float(probabilities[1])
|
| 93 |
+
|
| 94 |
+
prediction_class = 'Pneumonia' if pneumonia_prob > normal_prob else 'Normal'
|
| 95 |
+
confidence_score = max(normal_prob, pneumonia_prob)
|
| 96 |
+
|
| 97 |
+
logging.info(f'Inference completed: Predicted class - {prediction_class}, Confidence score - {confidence_score:.4f}')
|
| 98 |
+
|
| 99 |
+
# Execute Grad-CAM calculation
|
| 100 |
+
# Target the last dense block layer of DenseNet-121 for tracking applications
|
| 101 |
+
|
| 102 |
+
target_layers = [model.features.denseblock4.denselayer16]
|
| 103 |
+
# Prepare standard float array of the image for overlay mapping
|
| 104 |
+
rgb_img_np = np.array(image.resize((224, 224)), dtype=np.float32) / 255.0
|
| 105 |
+
|
| 106 |
+
cam = GradCAM(model=model, target_layers=target_layers)
|
| 107 |
+
|
| 108 |
+
# Target the predicted class index specifically to see its activation layout
|
| 109 |
+
target_category = 1 if prediction_class == "Pneumonia" else 0
|
| 110 |
+
targets = [ClassifierOutputTarget(target_category)]
|
| 111 |
+
|
| 112 |
+
# Compute visual grayscale activation map
|
| 113 |
+
grayscale_cam = cam(input_tensor=tensor_img, targets=targets)[0, :]
|
| 114 |
+
|
| 115 |
+
# Blend the grayscale map into a colored heatmap directly onto our structural input image
|
| 116 |
+
cam_image_overlay = show_cam_on_image(rgb_img_np, grayscale_cam, use_rgb=True)
|
| 117 |
+
|
| 118 |
+
# Step C: Encode Heatmap into binary buffer for Cloudinary output conversion
|
| 119 |
+
# Convert RGB array back to standard BGR image array layout for OpenCV conversion
|
| 120 |
+
cam_image_bgr = cv2.cvtColor((cam_image_overlay * 255).astype(np.uint8), cv2.COLOR_RGB2BGR)
|
| 121 |
+
_, buffer = cv2.imencode('.jpg', cam_image_bgr)
|
| 122 |
+
heatmap_bytes = buffer.tobytes()
|
| 123 |
|
| 124 |
+
return {
|
| 125 |
+
'prediction': prediction_class,
|
| 126 |
+
'confidence': confidence_score,
|
| 127 |
+
'probabilities': {'Normal': normal_prob, 'Pneumonia': pneumonia_prob},
|
| 128 |
+
'heatmap_bytes': heatmap_bytes
|
| 129 |
+
}
|
backend/routers/predict.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
from fastapi import APIRouter, File, UploadFile, HTTPException
|
| 2 |
-
from
|
|
|
|
|
|
|
| 3 |
from ..database import upload_image_to_cloud, diagnostic_records
|
| 4 |
from ..ml_model import run_diagnostic_inference
|
| 5 |
|
|
@@ -12,15 +14,22 @@ router = APIRouter(
|
|
| 12 |
# Enforce strict typing on the response using response_model
|
| 13 |
@router.post('/predict', response_model=PredictionResponse)
|
| 14 |
async def predict_xray(file: UploadFile = File(...)):
|
| 15 |
-
|
| 16 |
Analyzes an uploaded X-ray, uploads to Cloudinary, saves to MongoDB,
|
| 17 |
and returns strictly typed results.
|
| 18 |
-
|
| 19 |
if not file.content_type.startswith('image/'):
|
| 20 |
raise HTTPException(status_code=400, detail='Invalid file format. Please upload an image.')
|
| 21 |
|
| 22 |
try:
|
| 23 |
image_bytes = await file.read()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
ai_result = run_diagnostic_inference(image_bytes)
|
| 25 |
|
| 26 |
original_url = upload_image_to_cloud(image_bytes, folder_name='raw_xrays')
|
|
@@ -32,7 +41,8 @@ async def predict_xray(file: UploadFile = File(...)):
|
|
| 32 |
heatmap_image_url=heatmap_url,
|
| 33 |
prediction=ai_result['prediction'],
|
| 34 |
confidence_score=ai_result['confidence'],
|
| 35 |
-
probabilities=ai_result['probabilities']
|
|
|
|
| 36 |
)
|
| 37 |
|
| 38 |
# model_dump() converts the Pydantic model safely to a dictionary for MongoDB
|
|
@@ -54,9 +64,9 @@ async def predict_xray(file: UploadFile = File(...)):
|
|
| 54 |
|
| 55 |
@router.put('/override/{record_id}')
|
| 56 |
async def submit_physician_override(record_id: str, override_data: OverrideRequest):
|
| 57 |
-
|
| 58 |
Updates a specific diagnostic record with the physician's feedback.
|
| 59 |
-
|
| 60 |
try:
|
| 61 |
# Update the MongoDB document matching the ID
|
| 62 |
update_result = await diagnostic_records.update_one(
|
|
|
|
| 1 |
from fastapi import APIRouter, File, UploadFile, HTTPException
|
| 2 |
+
from bson import ObjectId
|
| 3 |
+
import random
|
| 4 |
+
from ..schema import PredictionResponse, DiagnosticRecordSchema, OverrideRequest
|
| 5 |
from ..database import upload_image_to_cloud, diagnostic_records
|
| 6 |
from ..ml_model import run_diagnostic_inference
|
| 7 |
|
|
|
|
| 14 |
# Enforce strict typing on the response using response_model
|
| 15 |
@router.post('/predict', response_model=PredictionResponse)
|
| 16 |
async def predict_xray(file: UploadFile = File(...)):
|
| 17 |
+
"""
|
| 18 |
Analyzes an uploaded X-ray, uploads to Cloudinary, saves to MongoDB,
|
| 19 |
and returns strictly typed results.
|
| 20 |
+
"""
|
| 21 |
if not file.content_type.startswith('image/'):
|
| 22 |
raise HTTPException(status_code=400, detail='Invalid file format. Please upload an image.')
|
| 23 |
|
| 24 |
try:
|
| 25 |
image_bytes = await file.read()
|
| 26 |
+
|
| 27 |
+
# Select a model with given weights
|
| 28 |
+
assigned_model = random.choices(['DenseNet121_v1', 'ResNet50_Challenger'],
|
| 29 |
+
weights=[1, 0], # To train another model in the future
|
| 30 |
+
k=1)[0] # For now, we only have DenseNet121_v1, so it gets 100% of the traffic. This is where A/B testing logic would go in the future.
|
| 31 |
+
|
| 32 |
+
|
| 33 |
ai_result = run_diagnostic_inference(image_bytes)
|
| 34 |
|
| 35 |
original_url = upload_image_to_cloud(image_bytes, folder_name='raw_xrays')
|
|
|
|
| 41 |
heatmap_image_url=heatmap_url,
|
| 42 |
prediction=ai_result['prediction'],
|
| 43 |
confidence_score=ai_result['confidence'],
|
| 44 |
+
probabilities=ai_result['probabilities'],
|
| 45 |
+
model_variant=assigned_model
|
| 46 |
)
|
| 47 |
|
| 48 |
# model_dump() converts the Pydantic model safely to a dictionary for MongoDB
|
|
|
|
| 64 |
|
| 65 |
@router.put('/override/{record_id}')
|
| 66 |
async def submit_physician_override(record_id: str, override_data: OverrideRequest):
|
| 67 |
+
"""
|
| 68 |
Updates a specific diagnostic record with the physician's feedback.
|
| 69 |
+
"""
|
| 70 |
try:
|
| 71 |
# Update the MongoDB document matching the ID
|
| 72 |
update_result = await diagnostic_records.update_one(
|