File size: 5,154 Bytes
21598fa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | """Standalone inference helpers for MALUNet on CVC-ClinicDB.
`load_model` accepts either a local checkpoint path or an "<owner>/<repo>"
reference to a Hugging Face model repository (it downloads `best.pth`).
CLI:
python infer.py --weights ./best.pth --image polyp.png --out mask.png
python infer.py --weights jane-l/malunet-cvc --image polyp.png --out mask.png
"""
import argparse
import io
import os
from pathlib import Path
from typing import Tuple, Union
import numpy as np
import torch
from PIL import Image
from models.malunet import MALUNet
DEFAULT_MODEL_CONFIG = {
"num_classes": 1,
"input_channels": 3,
"c_list": [8, 16, 24, 32, 48, 64],
"split_att": "fc",
"bridge": True,
}
INPUT_SIZE = 256
NORM_MEAN = 109.0
NORM_STD = 75.0
def _build():
return MALUNet(
num_classes=DEFAULT_MODEL_CONFIG["num_classes"],
input_channels=DEFAULT_MODEL_CONFIG["input_channels"],
c_list=DEFAULT_MODEL_CONFIG["c_list"],
split_att=DEFAULT_MODEL_CONFIG["split_att"],
bridge=DEFAULT_MODEL_CONFIG["bridge"],
)
def _is_hf_repo_id(s: str) -> bool:
if os.path.exists(s):
return False
return "/" in s and not s.endswith(".pth") and not s.endswith(".pt")
def _strip_module_prefix(state_dict):
return {k[7:] if k.startswith("module.") else k: v for k, v in state_dict.items()}
def load_model(weights: str, device: Union[str, torch.device, None] = None) -> torch.nn.Module:
if device is None:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
elif isinstance(device, str):
device = torch.device(device)
if _is_hf_repo_id(weights):
from huggingface_hub import hf_hub_download
weights = hf_hub_download(repo_id=weights, filename="best.pth")
state = torch.load(weights, map_location="cpu")
if isinstance(state, dict) and "model_state_dict" in state:
state = state["model_state_dict"]
state = _strip_module_prefix(state)
model = _build()
model.load_state_dict(state, strict=True)
model.to(device).eval()
return model
def _preprocess(img: Image.Image) -> Tuple[torch.Tensor, Tuple[int, int]]:
"""RGB PIL image -> normalized (1,3,H,W) tensor. Returns the original (H,W)."""
img = img.convert("RGB")
orig_size = img.size[::-1] # (H, W)
arr = np.asarray(img, dtype=np.float32)
arr = (arr - NORM_MEAN) / NORM_STD
lo, hi = arr.min(), arr.max()
if hi > lo:
arr = (arr - lo) / (hi - lo) * 255.0
else:
arr = np.zeros_like(arr)
img_resized = Image.fromarray(arr.astype(np.uint8)).resize(
(INPUT_SIZE, INPUT_SIZE), Image.BILINEAR
)
t = torch.from_numpy(np.asarray(img_resized, dtype=np.float32)).permute(2, 0, 1).unsqueeze(0)
return t, orig_size
@torch.no_grad()
def predict_mask(
model: torch.nn.Module,
image: Union[str, Path, Image.Image, bytes],
threshold: float = 0.5,
return_prob: bool = False,
) -> np.ndarray:
"""Returns a uint8 mask resized back to the original image resolution."""
if isinstance(image, (str, Path)):
img = Image.open(image)
elif isinstance(image, bytes):
img = Image.open(io.BytesIO(image))
elif isinstance(image, Image.Image):
img = image
else:
raise TypeError(f"unsupported image type: {type(image)}")
device = next(model.parameters()).device
t, (h, w) = _preprocess(img)
t = t.to(device).float()
out = model(t) # (1,1,256,256), already sigmoid
prob = out[0, 0].cpu().numpy()
prob_full = np.array(
Image.fromarray((prob * 255).astype(np.uint8)).resize((w, h), Image.BILINEAR),
dtype=np.float32,
) / 255.0
if return_prob:
return prob_full
return (prob_full >= threshold).astype(np.uint8) * 255
def overlay(image: Image.Image, mask: np.ndarray, alpha: float = 0.45) -> Image.Image:
base = image.convert("RGB")
bw, bh = base.size
if mask.shape != (bh, bw):
mask = np.array(Image.fromarray(mask).resize((bw, bh), Image.NEAREST))
color = np.zeros((bh, bw, 3), dtype=np.uint8)
color[..., 0] = mask # red
base_arr = np.asarray(base, dtype=np.float32)
mask_bool = mask > 0
blended = base_arr.copy()
blended[mask_bool] = (1 - alpha) * base_arr[mask_bool] + alpha * color[mask_bool]
return Image.fromarray(blended.astype(np.uint8))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--weights", required=True, help="Local .pth path OR <owner>/<repo> on HF")
ap.add_argument("--image", required=True)
ap.add_argument("--out", default="mask.png")
ap.add_argument("--overlay-out", default=None, help="optional overlay PNG path")
ap.add_argument("--threshold", type=float, default=0.5)
args = ap.parse_args()
model = load_model(args.weights)
img = Image.open(args.image)
mask = predict_mask(model, img, threshold=args.threshold)
Image.fromarray(mask).save(args.out)
print(f"wrote {args.out}")
if args.overlay_out:
overlay(img, mask).save(args.overlay_out)
print(f"wrote {args.overlay_out}")
if __name__ == "__main__":
main()
|