yashMaini's picture
Run on ZeroGPU: add @spaces.GPU entry point, move inference to CUDA
2cabb68 verified
Raw
History Blame Contribute Delete
10.7 kB
"""Simple Gradio interface for testing the trained Gabor-UNet hyphae segmentation model.
Reproduces the exact preprocessing/model architecture from notebook-4
(vastai_gabor_unet) so the pretrained checkpoint loads and runs unmodified.
"""
import cv2
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import gradio as gr
import segmentation_models_pytorch as smp
# On HF Spaces free tier the app runs on ZeroGPU, which requires the entry point
# to be declared with @spaces.GPU — without it the Space exits with
# "No @spaces.GPU function detected during startup". Locally `spaces` isn't
# installed, so fall back to plain CPU execution and a no-op decorator.
try:
import spaces
ON_ZERO_GPU = True
except ImportError:
spaces = None
ON_ZERO_GPU = False
def gpu_task(duration=120):
"""Mark the inference entry point for ZeroGPU; no-op when running locally."""
def wrap(fn):
return spaces.GPU(duration=duration)(fn) if ON_ZERO_GPU else fn
return wrap
CKPT_PATH = "gabor_unet_best.pth"
TILE_SIZE = 512
GABOR_ORIENTATIONS = 6
GABOR_SCALES = 3
GABOR_KSIZE = 31
GABOR_STORE_SIZE = 64
DEFAULT_THRESHOLD = 0.75 # calibrated value, see threshold_calibration_results.csv
# ZeroGPU reports no CUDA device in the main process at import time, so this can't
# be probed with torch.cuda.is_available() — the `spaces` runtime attaches the GPU
# when the decorated function is called.
DEVICE = torch.device("cuda") if ON_ZERO_GPU else torch.device("cpu")
# ── Gabor filter bank (matches notebook-4 exactly) ──────────────────────────
def build_gabor_bank(orientations=6, scales=3, ksize=31):
kernels = []
for wl in [4, 8, 16][:scales]:
for i in range(orientations):
theta = (i / orientations) * np.pi
kernel = cv2.getGaborKernel(
ksize=(ksize, ksize), sigma=wl * 0.56,
theta=theta, lambd=float(wl),
gamma=0.5, psi=0, ktype=cv2.CV_32F,
)
kernel /= (kernel.sum() + 1e-8)
kernels.append(kernel)
return kernels
def compute_gabor_small(img_bgr, kernels, store_size=GABOR_STORE_SIZE):
small = cv2.resize(img_bgr, (128, 128), interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY).astype(np.float32) / 255.0
responses = np.stack([np.abs(cv2.filter2D(gray, cv2.CV_32F, k)) for k in kernels], axis=0)
max_r = responses.max(axis=0)
ori = responses.argmax(axis=0).astype(np.float32) / len(kernels)
feat = np.stack([max_r, ori], axis=-1)
if store_size != 128:
feat = cv2.resize(feat, (store_size, store_size), interpolation=cv2.INTER_LINEAR)
return feat.astype(np.float32)
GABOR_KERNELS = build_gabor_bank(GABOR_ORIENTATIONS, GABOR_SCALES, GABOR_KSIZE)
# ── Model (matches notebook-4 exactly) ──────────────────────────────────────
class GaborAttentionGate(nn.Module):
def __init__(self, feat_channels):
super().__init__()
self.gate = nn.Sequential(
nn.Conv2d(feat_channels + 1, feat_channels, kernel_size=1, bias=False),
nn.BatchNorm2d(feat_channels),
nn.Sigmoid(),
)
def forward(self, feat, gabor_max):
g = F.interpolate(gabor_max, size=feat.shape[2:], mode="bilinear", align_corners=False)
return feat * self.gate(torch.cat([feat, g], dim=1))
class GaborUNet(nn.Module):
def __init__(self, in_channels=5, pretrained=False):
super().__init__()
self.unet = smp.Unet(
encoder_name="efficientnet-b4",
encoder_weights="imagenet" if pretrained else None,
in_channels=in_channels,
classes=1,
activation=None,
)
self.attn_gate = GaborAttentionGate(feat_channels=16)
def forward(self, x):
gabor_max = x[:, 3:4, :, :]
features = self.unet.encoder(x)
decoder_output = self.unet.decoder(features)
decoder_output = self.attn_gate(decoder_output, gabor_max)
return self.unet.segmentation_head(decoder_output)
print("Loading model...")
model = GaborUNet(in_channels=5, pretrained=False)
ckpt = torch.load(CKPT_PATH, map_location="cpu", weights_only=False)
model.load_state_dict(ckpt["model_state"])
model.eval().to(DEVICE)
print(f"Loaded checkpoint — epoch {ckpt.get('epoch')} Val IoU: {ckpt.get('val_iou'):.4f}")
def load_image_any_format(file_path):
"""Load virtually any image file (jpg, png, tiff, webp, jp2/JPEG2000, bmp, ...) as RGB uint8."""
try:
from PIL import Image
im = Image.open(file_path)
im = im.convert("RGB")
return np.array(im)
except Exception:
pass
img = cv2.imread(file_path, cv2.IMREAD_COLOR)
if img is not None:
return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
raise gr.Error(f"Could not read this file as an image: {file_path}")
def pad_to_multiple(img, tile_size):
"""Reflect-pad so H and W are exact multiples of tile_size (matches notebook-1 tiling)."""
h, w = img.shape[:2]
pad_h = (tile_size - h % tile_size) % tile_size
pad_w = (tile_size - w % tile_size) % tile_size
if img.ndim == 3:
return np.pad(img, ((0, pad_h), (0, pad_w), (0, 0)), mode="reflect")
return np.pad(img, ((0, pad_h), (0, pad_w)), mode="reflect")
def tile_to_tensor(tile_rgb):
"""Build the 5-channel (RGB + Gabor) model input for one native-scale 512x512 tile."""
tile_bgr = cv2.cvtColor(tile_rgb, cv2.COLOR_RGB2BGR)
img_t = torch.from_numpy(tile_rgb).permute(2, 0, 1).float() / 255.0
feat = compute_gabor_small(tile_bgr, GABOR_KERNELS)
feat = cv2.resize(feat, (TILE_SIZE, TILE_SIZE), interpolation=cv2.INTER_LINEAR)
gabor_t = torch.from_numpy(feat.transpose(2, 0, 1))
return torch.cat([img_t, gabor_t], dim=0).unsqueeze(0)
CANDIDATE_SCALES = [1.0, 1.5, 2.0, 2.5]
EARLY_STOP_CONFIDENCE = 0.9
def run_tiled_inference(img_rgb):
"""Tile at native resolution (matching the training pipeline) and return the
full-resolution sigmoid probability map."""
padded = pad_to_multiple(img_rgb, TILE_SIZE)
H, W = padded.shape[:2]
prob_map = np.zeros((H, W), dtype=np.float32)
for y in range(0, H, TILE_SIZE):
for x in range(0, W, TILE_SIZE):
tile = padded[y:y + TILE_SIZE, x:x + TILE_SIZE]
x_in = tile_to_tensor(tile).to(DEVICE)
logits = model(x_in)
prob_map[y:y + TILE_SIZE, x:x + TILE_SIZE] = (
torch.sigmoid(logits).squeeze().float().cpu().numpy()
)
return prob_map
@gpu_task(duration=120)
@torch.no_grad()
def predict(file_path, threshold, auto_scale=True):
if file_path is None:
return None, None, ""
img_raw = load_image_any_format(file_path)
# A photo's microns-per-pixel almost never matches the calibrated slide scanner
# used for training, and that scale can't be inferred from the file itself.
# Measured fix: scale alone (no color/denoise changes) took a real off-distribution
# photo from max confidence 0.039 (undetectable) to 0.9950 at 2x scale. So: try
# native scale first, and only pay for larger, slower re-tries if confidence is
# low — this keeps already-good native-resolution uploads (like your JP2s) just
# as fast as before, since they hit the early-stop on the very first pass.
scales_to_try = CANDIDATE_SCALES if auto_scale else [1.0]
best_scale, best_conf, best_prob_map, best_img_scaled = scales_to_try[0], -1.0, None, img_raw
for s in scales_to_try:
if s != 1.0:
h, w = img_raw.shape[:2]
new_h = max(TILE_SIZE, int(round(h * s)))
new_w = max(TILE_SIZE, int(round(w * s)))
img_s = cv2.resize(img_raw, (new_w, new_h), interpolation=cv2.INTER_CUBIC)
else:
img_s = img_raw
h0, w0 = img_s.shape[:2]
prob_map = run_tiled_inference(img_s)[:h0, :w0]
conf = float(prob_map.max())
if conf > best_conf:
best_scale, best_conf, best_prob_map, best_img_scaled = s, conf, prob_map, img_s
if conf >= EARLY_STOP_CONFIDENCE:
break
pred = (best_prob_map > threshold).astype(np.uint8)
mask_rgb = np.stack([pred * 255] * 3, axis=-1).astype(np.uint8)
overlay = best_img_scaled.copy()
overlay[pred == 1] = (
0.5 * overlay[pred == 1] + 0.5 * np.array([255, 0, 0])
).astype(np.uint8)
info = f"Scale used: {best_scale}x | peak confidence: {best_conf:.3f}"
return mask_rgb, overlay, info
def preview_image(file_path):
if file_path is None:
return None
return load_image_any_format(file_path)
with gr.Blocks(title="Gabor U-Net — Hyphae Segmentation") as demo:
gr.Markdown(
"### Gabor U-Net — Hyphae Segmentation\n\n"
"<span style='font-size:0.85em;'>Upload any image (any format/size). It's reflect-padded and "
"split into native-resolution 512x512 tiles (matching the training pipeline — no downscaling), "
"each tile is run through the Gabor-attention U-Net (EfficientNet-B4 encoder), and predictions "
"are stitched back together. Large images may take a while.</span>"
)
with gr.Row():
with gr.Column():
file_input = gr.File(label="Input image (any format — jpg, png, tiff, webp, jp2, bmp, ...)", type="filepath", height=120)
preview = gr.Image(label="Preview of uploaded image", interactive=False)
threshold = gr.Slider(0.0, 1.0, value=DEFAULT_THRESHOLD, step=0.01, label="Threshold (calibrated default: 0.75)")
auto_scale = gr.Checkbox(
value=True,
label="Auto-scale (recommended)",
info="Tries native scale first; only retries at 1.5x/2x/2.5x if confidence is low — "
"fixes photos where hyphae render too thin/thick vs. training tiles, at no extra "
"cost to already-good uploads.",
)
submit_btn = gr.Button("Submit", variant="primary")
with gr.Column():
mask_out = gr.Image(label="Predicted mask")
overlay_out = gr.Image(label="Overlay")
scale_info = gr.Textbox(label="Detection info", interactive=False)
file_input.change(fn=preview_image, inputs=file_input, outputs=preview)
submit_btn.click(fn=predict, inputs=[file_input, threshold, auto_scale], outputs=[mask_out, overlay_out, scale_info])
if __name__ == "__main__":
demo.launch()