HSCRinference / app.py
hamnaraeel's picture
Upload app.py with huggingface_hub
c8625b5 verified
Raw
History Blame Contribute Delete
15.1 kB
"""
app.py β€” HistoPath DX (single-image inference server)
Matches the Kaggle inference script exactly:
- Backbone : facebook/mask2former-swin-small-coco-instance
- SEG_CKPT : best_m2f.pth (Mask2Former backbone weights)
- CLS_CKPT : best_m2f_classifier.pth (full StrongerMask2FormerClassifier)
- Vahadane : fit once on REFERENCE_IMAGE_PATH at startup
- /predict : POST image β†’ Vahadane β†’ processor β†’ model β†’ JSON + overlay PNG
"""
import io, os, base64
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.cm as cm_lib
from PIL import Image as PILImage
from flask import Flask, request, jsonify, render_template
from flask_cors import CORS
from transformers import (Mask2FormerForUniversalSegmentation,
Mask2FormerImageProcessor)
# =============================================================================
# ── CONFIGURATION (edit these paths before running) ─────────────────────────
# =============================================================================
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
REFERENCE_IMAGE_PATH = os.path.join(BASE_DIR, "reference.jpg")
SEG_CKPT = os.path.join(BASE_DIR, "best_m2f.pth")
CLS_CKPT = os.path.join(BASE_DIR, "best_m2f_classifier.pth")
_BACKBONE_HUB = "facebook/mask2former-swin-small-coco-instance"
SEG_THR = 0.5 # instance segmentation confidence threshold
CLS_THR = 0.5 # classification decision threshold
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
id2label = {0: "ganglion_cell"}
label2id = {"ganglion_cell": 0}
CLS_NAMES = {0: "non_diseased", 1: "diseased"}
# =============================================================================
# ── VAHADANE NORMALISER ───────────────────────────────────────────────────────
# =============================================================================
try:
import spams
SPAMS_AVAILABLE = True
except ImportError:
from sklearn.decomposition import NMF
SPAMS_AVAILABLE = False
def _rgb_to_od(img):
return -np.log(np.clip(img.astype(np.float64), 1, 254) / 255.0)
def _od_to_rgb(od):
return np.clip(np.exp(-od) * 255.0, 0, 255).astype(np.uint8)
def _tissue_mask(img, thresh=0.15):
return _rgb_to_od(img).sum(axis=2) > thresh
class VahadaneNormalizer:
def __init__(self, lambda1=0.1, max_iter=3):
self.lambda1 = lambda1
self.max_iter = max_iter
self.target_stain_matrix = None
self.target_concentrations_max = None
def _stain_matrix(self, img):
mask = _tissue_mask(img)
OD = _rgb_to_od(img)
OD_t = OD[mask].T
if OD_t.shape[1] < 10:
return np.array([[0.5626, 0.2159],
[0.7201, 0.8012],
[0.4062, 0.5581]])
if SPAMS_AVAILABLE:
D = spams.trainDL(
np.asfortranarray(OD_t.astype(np.float64)),
K=2, lambda1=self.lambda1, iter=self.max_iter,
mode=2, modeD=0, posAlpha=True, posD=True, verbose=False)
else:
model = NMF(n_components=2, init="nndsvda",
max_iter=500, random_state=42)
model.fit(np.maximum(OD_t.T, 0))
D = model.components_.T
D = D / (np.linalg.norm(D, axis=0, keepdims=True) + 1e-8)
if D[2, 0] > D[2, 1]:
D = D[:, [1, 0]]
return D
def fit(self, ref_img):
self.target_stain_matrix = self._stain_matrix(ref_img)
OD = _rgb_to_od(ref_img)
mask = _tissue_mask(ref_img)
C, _, _, _ = np.linalg.lstsq(self.target_stain_matrix,
OD[mask].T, rcond=None)
self.target_concentrations_max = np.percentile(C, 99, axis=1)
return self
def normalize(self, img):
h, w = img.shape[:2]
W = self._stain_matrix(img)
OD = _rgb_to_od(img).reshape(-1, 3)
C, _, _, _ = np.linalg.lstsq(W, OD.T, rcond=None)
maxC = np.percentile(C, 99, axis=1, keepdims=True)
maxC = np.where(maxC < 1e-6, 1e-6, maxC)
C = C / maxC * self.target_concentrations_max[:, None]
return _od_to_rgb((self.target_stain_matrix @ C).T.reshape(h, w, 3))
# =============================================================================
# ── MODEL ARCHITECTURE (must match training exactly) ─────────────────────────
# =============================================================================
class SpatialAttentionPool(nn.Module):
def __init__(self, in_channels):
super().__init__()
self.attn = nn.Sequential(
nn.Conv2d(in_channels, 64, kernel_size=1, bias=False),
nn.ReLU(inplace=True),
nn.Conv2d(64, 1, kernel_size=1, bias=False),
)
def forward(self, x):
w = self.attn(x).flatten(2).softmax(dim=-1)
return (x.flatten(2) * w).sum(dim=-1)
class MaskGuidedPool(nn.Module):
def forward(self, features, mask_labels):
B, C, Hp, Wp = features.shape
pooled = []
for b in range(B):
masks = mask_labels[b]
if masks.numel() == 0 or masks.shape[0] == 0:
pooled.append(features[b].mean(dim=(-2, -1)))
continue
union = masks.max(dim=0).values
union_r = F.interpolate(
union.unsqueeze(0).unsqueeze(0).float(),
size=(Hp, Wp), mode="bilinear", align_corners=False,
).squeeze(0).squeeze(0).to(features.device)
w_sum = union_r.sum().clamp(min=1e-6)
pooled.append(
(features[b] * union_r.unsqueeze(0)).sum(dim=(-2, -1)) / w_sum)
return torch.stack(pooled, dim=0)
class StrongerMask2FormerClassifier(nn.Module):
def __init__(self, m2f_backbone, in_channels=256, num_classes=2):
super().__init__()
self.m2f = m2f_backbone
self.spatial_pool = SpatialAttentionPool(in_channels)
self.mask_pool = MaskGuidedPool()
self.cls_head = nn.Sequential(
nn.Linear(in_channels * 2, 256),
nn.LayerNorm(256),
nn.GELU(),
nn.Dropout(0.4),
nn.Linear(256, 64),
nn.GELU(),
nn.Dropout(0.2),
nn.Linear(64, num_classes),
)
def forward(self, pixel_values, pixel_mask=None,
mask_labels=None, class_labels=None):
outputs = self.m2f(
pixel_values=pixel_values,
pixel_mask=pixel_mask,
mask_labels=mask_labels,
class_labels=class_labels,
output_hidden_states=True,
)
feat = outputs.pixel_decoder_last_hidden_state
v_spatial = self.spatial_pool(feat)
# mask_labels=None at inference β†’ spatial attention fallback
v_mask = (self.mask_pool(feat, mask_labels)
if mask_labels is not None
else self.spatial_pool(feat))
cls_logits = self.cls_head(torch.cat([v_spatial, v_mask], dim=1))
seg_loss = outputs.loss if mask_labels is not None else None
return cls_logits, seg_loss
# =============================================================================
# ── STARTUP: Vahadane β†’ processor β†’ model ────────────────────────────────────
# =============================================================================
print(f"Device: {DEVICE}")
# --- Vahadane ---
vahadane = None
if os.path.exists(REFERENCE_IMAGE_PATH):
ref_np = np.array(PILImage.open(REFERENCE_IMAGE_PATH).convert("RGB"))
vahadane = VahadaneNormalizer(lambda1=0.1, max_iter=3)
vahadane.fit(ref_np)
else:
pass
# --- Processor (same config as training) ---
processor = Mask2FormerImageProcessor.from_pretrained(
_BACKBONE_HUB,
do_resize=True,
size={"shortest_edge": 512, "longest_edge": 1024},
do_normalize=True,
)
print("βœ… Processor ready.")
# --- Model ---
model = None
try:
assert os.path.exists(SEG_CKPT), f"SEG_CKPT not found: {SEG_CKPT}"
assert os.path.exists(CLS_CKPT), f"CLS_CKPT not found: {CLS_CKPT}"
backbone = Mask2FormerForUniversalSegmentation.from_pretrained(
_BACKBONE_HUB,
id2label=id2label,
label2id=label2id,
ignore_mismatched_sizes=True,
)
backbone.load_state_dict(
torch.load(SEG_CKPT, map_location="cpu"), strict=False)
print(f"βœ… Segmentation backbone loaded: {SEG_CKPT}")
model = StrongerMask2FormerClassifier(backbone, num_classes=2).to(DEVICE)
model.load_state_dict(
torch.load(CLS_CKPT, map_location=DEVICE), strict=False)
model.eval()
print(f"βœ… Classifier loaded: {CLS_CKPT}")
except Exception as e:
print(f"❌ Model loading failed: {e}")
# =============================================================================
# ── INFERENCE HELPER ──────────────────────────────────────────────────────────
# =============================================================================
CMAP = cm_lib.get_cmap("tab10")
def run_inference(raw_np: np.ndarray) -> dict:
"""
Mirrors save_inference_viz() from the Kaggle script exactly.
raw_np : HΓ—WΓ—3 uint8 numpy array
Returns dict:
prediction : 0 | 1
probability : float P(diseased)
label : "non_diseased" | "diseased"
n_segments : int
overlay_b64 : base64-encoded PNG (two-panel: original | normalised+seg)
"""
# 1. Vahadane normalisation
if vahadane is not None:
try:
norm_np = vahadane.normalize(raw_np)
except Exception as e:
print(f"⚠️ Vahadane failed ({e}) β€” using raw image.")
norm_np = raw_np
else:
norm_np = raw_np
norm_pil = PILImage.fromarray(norm_np)
raw_pil = PILImage.fromarray(raw_np)
# 2. Processor β€” same settings as TestDataset in Kaggle script
inputs = processor(images=[norm_pil], return_tensors="pt")
pv = inputs["pixel_values"].to(DEVICE) # (1, 3, H', W')
pm = inputs["pixel_mask"].to(DEVICE) # (1, H', W')
_, _, proc_h, proc_w = pv.shape
# 3. Forward pass
with torch.no_grad():
# Raw M2F output needed for post_process_instance_segmentation
raw_out = model.m2f(pixel_values=pv, pixel_mask=pm)
# Full classifier forward (mask_labels=None β†’ spatial fallback)
cls_logits, _ = model(pv, pm, mask_labels=None)
probs = torch.softmax(cls_logits, dim=1)[0].cpu()
prob_dis = float(probs[1].item())
pred_class = int(prob_dis >= CLS_THR)
label = CLS_NAMES[pred_class]
# 4. Instance segmentation post-processing (mirrors Kaggle script)
res = processor.post_process_instance_segmentation(
raw_out,
target_sizes=[(proc_h, proc_w)],
threshold=SEG_THR,
)[0]
pred_seg = res["segmentation"].cpu().numpy() # (proc_h, proc_w) int
segments = res["segments_info"]
# 5. Resize segmentation map to display (original) image size
display_w, display_h = norm_pil.size
seg_pil = PILImage.fromarray(pred_seg.astype(np.int32)).resize(
(display_w, display_h), resample=PILImage.NEAREST)
seg_disp = np.array(seg_pil)
# 6. Two-panel figure (original | Vahadane-normalised + seg overlay)
# Matches save_inference_viz() layout exactly
fig, axes = plt.subplots(1, 2, figsize=(14, 6), facecolor="#0a0f1e")
for ax in axes:
ax.set_facecolor("#0a0f1e")
# Left β€” original image (no overlay)
axes[0].imshow(raw_pil)
axes[0].set_title("Input Image",
color="white", fontsize=12, pad=10)
axes[0].axis("off")
# Right β€” Vahadane-normalised + instance mask overlays
axes[1].imshow(raw_pil)
for si, seg in enumerate(segments):
overlay = np.zeros((*seg_disp.shape, 4))
mask_region = seg_disp == seg["id"]
colour = CMAP(si % 10)[:3]
overlay[mask_region] = (*colour, 0.45)
axes[1].imshow(overlay)
axes[1].set_title(
f"Segmentation | {len(segments)} segment(s) detected",
color="white", fontsize=12, pad=10)
axes[1].axis("off")
cls_colour = "#ff4444" if pred_class == 1 else "#44ff88"
verdict = ("⚠ DISEASED β€” Ganglion cells detected"
if pred_class == 1
else "βœ“ NON-DISEASED β€” No ganglion cells detected")
fig.suptitle(
f"{verdict} | Confidence: {prob_dis:.1%}",
color=cls_colour, fontsize=14, fontweight="bold", y=1.01)
plt.tight_layout()
buf = io.BytesIO()
plt.savefig(buf, format="png", dpi=130,
bbox_inches="tight", facecolor="#0a0f1e")
plt.close(fig)
buf.seek(0)
overlay_b64 = base64.b64encode(buf.read()).decode("utf-8")
return {
"prediction": pred_class,
"probability": round(prob_dis, 4),
"label": label,
"n_segments": len(segments),
"overlay_b64": overlay_b64,
}
# =============================================================================
# ── FLASK ROUTES ──────────────────────────────────────────────────────────────
# =============================================================================
app = Flask(__name__)
CORS(app)
@app.route("/")
def index():
return render_template("index.html")
@app.route("/predict", methods=["POST"])
def predict():
# Validate upload
if "image" not in request.files:
return jsonify({"error": "No image file in request."}), 400
file = request.files["image"]
if not file or file.filename == "":
return jsonify({"error": "Empty filename."}), 400
if not file.filename.lower().endswith((".jpg", ".jpeg", ".png")):
return jsonify({"error": "Only JPG and PNG are accepted."}), 400
# Load raw image
try:
raw_np = np.array(
PILImage.open(io.BytesIO(file.read())).convert("RGB"))
except Exception as e:
return jsonify({"error": f"Could not read image: {e}"}), 400
if model is None:
return jsonify({"error": "Model not loaded β€” check server logs."}), 503
try:
result = run_inference(raw_np)
except Exception as e:
import traceback; traceback.print_exc()
return jsonify({"error": f"Inference failed: {e}"}), 500
return jsonify(result)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=7860, debug=False)