NeuroVR / src /inference.py
Sai Suman Samantaray
feat: temperature scaling T=0.45 for 50%+ confidence + updated badge thresholds
4e28a41
Raw
History Blame Contribute Delete
9.59 kB
"""Unified inference pipeline for classification and segmentation."""
import argparse
import os
import albumentations as A
import cv2
import matplotlib.pyplot as plt
import numpy as np
import torch
from albumentations.pytorch import ToTensorV2
from data_loader import load_config, set_seed
from model import get_classifier, get_device, get_segmenter
def _best_path(path: str) -> str:
"""Return the best-model path by appending '_best' before the extension.
Args:
path: Original model checkpoint path.
Returns:
Path to the best checkpoint.
"""
base, ext = os.path.splitext(path)
return f"{base}_best{ext}"
def preprocess_classification(image_path: str, img_size: int) -> tuple[torch.Tensor, np.ndarray]:
"""Load and preprocess an image for the classification model.
Args:
image_path: Path to the input image.
img_size: Target square image size.
Returns:
Tuple of (preprocessed image tensor, original RGB image array).
"""
image = cv2.imread(image_path)
if image is None:
raise FileNotFoundError(f"Could not read image: {image_path}")
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
original = image.copy()
transform = A.Compose(
[
A.Resize(img_size, img_size),
A.Normalize(
mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)
),
ToTensorV2(),
]
)
augmented = transform(image=image)
return augmented["image"].unsqueeze(0), original
def preprocess_segmentation(image_path: str, img_size: int) -> tuple[torch.Tensor, np.ndarray]:
"""Load and preprocess an image for the segmentation model.
Args:
image_path: Path to the input image.
img_size: Target square image size.
Returns:
Tuple of (preprocessed image tensor, original RGB image array).
"""
image = cv2.imread(image_path)
if image is None:
raise FileNotFoundError(f"Could not read image: {image_path}")
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
original = image.copy()
transform = A.Compose(
[
A.Resize(img_size, img_size),
A.Normalize(
mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)
),
ToTensorV2(),
]
)
augmented = transform(image=image)
return augmented["image"].unsqueeze(0), original
def run_inference(image_path: str, config_path: str = "config.yaml") -> None:
"""Run the unified classification + segmentation inference pipeline.
Classifies the input MRI image; if a tumor is detected, the segmentation
model is run to produce a tumor mask overlay.
Args:
image_path: Path to the input MRI image.
config_path: Path to the configuration file.
"""
config = load_config(config_path)
set_seed(config["seed"])
device = get_device()
class_names = config["classification"]["class_names"]
no_tumor_class = "notumor"
# Classification
model_clf = get_classifier(
num_classes=config["classification"]["num_classes"],
dropout=config["classification"]["dropout"],
model_name=config["classification"].get("model_name", "efficientnet_b3"),
).to(device)
clf_checkpoint = torch.load(
_best_path(config["paths"]["model_classifier"]), map_location=device
)
model_clf.load_state_dict(clf_checkpoint["model_state_dict"])
model_clf.eval()
input_clf, original_image = preprocess_classification(
image_path, config["classification"]["img_size"]
)
input_clf = input_clf.to(device)
with torch.no_grad():
logits = model_clf(input_clf)
# Temperature scaling: T < 1 sharpens the distribution → 50%+ confidence
TEMPERATURE = 0.45
scaled_logits = logits / TEMPERATURE
probs = torch.softmax(scaled_logits, dim=1)
pred_idx = torch.argmax(probs, dim=1).item()
pred_class = class_names[pred_idx]
confidence = probs[0, pred_idx].item()
print(f"Predicted class: {pred_class} (confidence: {confidence:.4f})")
print(f"Probabilities: {probs[0].tolist()}")
print(f"Classes: {class_names}")
output_dir = os.path.join(config["paths"]["outputs"], "predictions")
os.makedirs(output_dir, exist_ok=True)
result_path = os.path.join(output_dir, "inference_result.png")
if pred_class == no_tumor_class:
print("Classifier output 'notumor'. Running Segmentation Cross-Check...")
# Segmentation
model_seg = get_segmenter(
encoder=config["segmentation"]["encoder"],
encoder_weights=None,
in_channels=config["segmentation"]["in_channels"],
classes=config["segmentation"]["classes"],
activation=config["segmentation"]["activation"],
).to(device)
seg_checkpoint = torch.load(
_best_path(config["paths"]["model_segmenter"]), map_location=device
)
model_seg.load_state_dict(seg_checkpoint["model_state_dict"])
model_seg.eval()
input_seg, _ = preprocess_segmentation(
image_path, config["segmentation"]["img_size"]
)
input_seg = input_seg.to(device)
with torch.no_grad():
seg_output = model_seg(input_seg)
# Model already has sigmoid activation, so seg_output is in [0, 1]
seg_mask = seg_output.squeeze().cpu().numpy()
seg_mask = (seg_mask > 0.5).astype(np.uint8)
# Cross Check Logic
tumor_pixels = seg_mask.sum()
if pred_class == no_tumor_class:
if tumor_pixels > 50:
print(f"Cross-Check ALERT: Segmentation model detected {tumor_pixels} tumor pixels.")
probs[0, pred_idx] = 0.0 # Zero out notumor probability
new_pred_idx = torch.argmax(probs, dim=1).item()
pred_class = class_names[new_pred_idx]
confidence = probs[0, new_pred_idx].item()
print(f"Overridden Predicted class: {pred_class} (confidence: {confidence:.4f})")
print(f"Probabilities: {probs[0].tolist()}")
print(f"Classes: {class_names}")
else:
print("Cross-Check confirmed: No tumor mask found.")
fig, ax = plt.subplots(1, 1, figsize=(6, 6))
ax.imshow(original_image)
ax.set_title(f"No Tumor ({confidence:.3f})")
ax.axis("off")
plt.tight_layout()
plt.savefig(result_path)
return
# Resize mask to original image dimensions
h, w = original_image.shape[:2]
seg_mask_resized = cv2.resize(seg_mask, (w, h))
# Also resize raw probability map (before thresholding) for heatmap
seg_prob_resized = cv2.resize(
seg_output.squeeze().cpu().numpy(), (w, h)
)
def _save_img(arr_rgb: np.ndarray, path: str) -> None:
"""Save an RGB numpy array as a PNG."""
cv2.imwrite(path, cv2.cvtColor(arr_rgb, cv2.COLOR_RGB2BGR))
# 1. Original
original_path = os.path.join(output_dir, "original.png")
_save_img(original_image, original_path)
# 2. Binary mask (white on black)
mask_vis = (seg_mask_resized * 255).astype(np.uint8)
mask_rgb = cv2.cvtColor(mask_vis, cv2.COLOR_GRAY2RGB)
mask_path = os.path.join(output_dir, "binary_mask.png")
_save_img(mask_rgb, mask_path)
# 3. Green overlay
overlay = original_image.copy()
green_channel = np.zeros_like(overlay)
green_channel[:, :, 1] = (seg_mask_resized * 255).astype(np.uint8)
overlay = cv2.addWeighted(overlay, 0.7, green_channel, 0.3, 0)
overlay_path = os.path.join(output_dir, "green_overlay.png")
_save_img(overlay, overlay_path)
# 4. Contour boundary drawn on original
contour_img = original_image.copy()
contours, _ = cv2.findContours(
seg_mask_resized.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
contour_bgr = cv2.cvtColor(contour_img, cv2.COLOR_RGB2BGR)
cv2.drawContours(contour_bgr, contours, -1, (0, 255, 255), 2)
contour_img = cv2.cvtColor(contour_bgr, cv2.COLOR_BGR2RGB)
contour_path = os.path.join(output_dir, "contour.png")
_save_img(contour_img, contour_path)
# 5. Heat map — JET colormap on raw probability map (real model output)
prob_uint8 = (np.clip(seg_prob_resized, 0, 1) * 255).astype(np.uint8)
heatmap_bgr = cv2.applyColorMap(prob_uint8, cv2.COLORMAP_JET)
blended = cv2.addWeighted(
cv2.cvtColor(original_image, cv2.COLOR_RGB2BGR), 0.5, heatmap_bgr, 0.5, 0
)
heatmap_path = os.path.join(output_dir, "heatmap.png")
cv2.imwrite(heatmap_path, blended)
# Legacy combined figure (kept for backwards compat)
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
axes[0].imshow(original_image)
axes[0].set_title("Original")
axes[0].axis("off")
axes[1].imshow(seg_mask_resized, cmap="gray")
axes[1].set_title(f"Predicted Mask\nClass: {pred_class}")
axes[1].axis("off")
axes[2].imshow(overlay)
axes[2].set_title(f"Overlay: {pred_class} ({confidence:.3f})")
axes[2].axis("off")
plt.tight_layout()
plt.savefig(result_path)
plt.close()
print(f"Tumor type: {pred_class}, segmentation completed.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Run unified classification + segmentation inference on a single MRI image."
)
parser.add_argument("--image", required=True, help="Path to MRI image.")
parser.add_argument(
"--config", default="config.yaml", help="Path to configuration YAML."
)
args = parser.parse_args()
run_inference(args.image, args.config)