CALHippo-Demo / inference_core.py
ecandeloro's picture
Add CALHippo Gradio demo
e605e3b verified
Raw
History Blame Contribute Delete
14.7 kB
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import albumentations as A
import cv2
import numpy as np
import rasterio.features
import torch
import torch.nn as nn
import yaml
from albumentations.pytorch import ToTensorV2
from dynamic_network_architectures.architectures.unet import PlainConvUNet
from huggingface_hub import hf_hub_download
from matplotlib import colormaps
from shapely.geometry import shape
from shapely.ops import unary_union
MODEL_REPO_ID = "AImageLab-Zip/CALHippo-Framework-Models"
MODEL_CONFIG_PATH = (
"density_estimation/short_unet/"
"9_shorter_unet_normalizedgame_asymclassnormalizedl1loss_adamw.yaml"
)
MODEL_WEIGHTS_PATH = "density_estimation/short_unet/final_density_model.pth"
CLASS_NAMES = ["Pyramidal", "Interneuron", "Astrocyte"]
CLASS_COLORS = np.array(
[
(214, 39, 40),
(0, 153, 170),
(31, 119, 180),
],
dtype=np.uint8,
)
@dataclass(frozen=True)
class LoadedModel:
model: nn.Module
transform: A.Compose
max_pix_value: float
patch_size: int
stride: int
num_classes: int
device: str
class PlainConvUNetReLU(nn.Module):
def __init__(
self,
base: PlainConvUNet,
num_classes: int,
output_scalers: list[float] | None = None,
output_activation: str = "relu",
) -> None:
super().__init__()
self.base = base
self.output_scaler = None
if output_scalers is not None:
self.output_scaler = nn.Parameter(
torch.tensor(output_scalers, dtype=torch.float32)
)
if output_activation.lower() == "relu":
self.output_act = nn.ReLU(inplace=False)
elif output_activation.lower() == "softplus":
self.output_act = nn.Softplus()
elif output_activation.lower() == "none":
self.output_act = nn.Identity()
else:
raise ValueError(f"Unsupported output activation: {output_activation}")
def forward(self, x: torch.Tensor) -> torch.Tensor:
out = self.base(x)
if self.output_scaler is not None:
out = out * self.output_scaler.view(1, -1, 1, 1)
return self.output_act(out)
def _build_model(config: dict[str, Any]) -> nn.Module:
model_config = config.get("MODEL", {})
kwargs = dict(model_config.get("kwargs", {}))
output_scalers = kwargs.pop("output_scalers", None)
output_activation = kwargs.pop("output_activation", "relu")
kwargs.pop("use_log_counts", None)
norm_ops = {
"BatchNorm2d": nn.BatchNorm2d,
"InstanceNorm2d": nn.InstanceNorm2d,
}
nonlins = {
"LeakyReLU": nn.LeakyReLU,
"ReLU": nn.ReLU,
"GELU": nn.GELU,
"PReLU": nn.PReLU,
"ELU": nn.ELU,
"SiLU": nn.SiLU,
}
kwargs["conv_op"] = nn.Conv2d
kwargs["dropout_op"] = None
kwargs["norm_op"] = norm_ops[kwargs.get("norm_op", "InstanceNorm2d")]
kwargs["nonlin"] = nonlins[kwargs.get("nonlin", "LeakyReLU")]
num_classes = int(model_config.get("num_classes", 3))
base = PlainConvUNet(
input_channels=int(model_config.get("input_channels", 3)),
num_classes=num_classes,
deep_supervision=bool(model_config.get("deep_supervision", False)),
**kwargs,
)
return PlainConvUNetReLU(
base=base,
num_classes=num_classes,
output_scalers=output_scalers,
output_activation=output_activation,
)
def load_demo_model() -> LoadedModel:
config_path = hf_hub_download(MODEL_REPO_ID, MODEL_CONFIG_PATH)
weights_path = hf_hub_download(MODEL_REPO_ID, MODEL_WEIGHTS_PATH)
with Path(config_path).open("r") as fh:
config = yaml.safe_load(fh) or {}
data_config = config.get("DATA", {})
patch_size = int(data_config.get("img_size", 128))
max_pix_value = float(data_config.get("fill_value", 65535))
norm_mean = tuple(data_config.get("norm_mean", [0.7637, 0.7637, 0.7637]))
norm_std = tuple(data_config.get("norm_std", [0.0703, 0.0703, 0.0703]))
model_config = config.get("MODEL", {})
num_classes = int(model_config.get("num_classes", 3))
transform = A.Compose(
[
A.PadIfNeeded(
min_height=patch_size,
min_width=patch_size,
border_mode=cv2.BORDER_CONSTANT,
fill=1.0,
fill_mask=0,
),
A.Normalize(mean=norm_mean, std=norm_std, max_pixel_value=1.0),
ToTensorV2(transpose_mask=True),
]
)
# The public demo targets the free CPU Basic Space tier.
device = "cpu"
model = _build_model(config).to(device)
try:
state_dict = torch.load(weights_path, map_location=device, weights_only=True)
except TypeError:
state_dict = torch.load(weights_path, map_location=device)
model.load_state_dict(state_dict)
model.eval()
return LoadedModel(
model=model,
transform=transform,
max_pix_value=max_pix_value,
patch_size=patch_size,
stride=patch_size // 2,
num_classes=num_classes,
device=device,
)
def load_low_res_wsi(
image_path: str | Path,
max_pix_value: float,
transform: A.Compose | None = None,
) -> tuple[np.ndarray | torch.Tensor, dict[str, int]]:
wsi = cv2.imread(str(image_path), cv2.IMREAD_UNCHANGED)
if wsi is None:
raise ValueError(f"Could not read image: {image_path}")
if wsi.ndim == 2:
wsi = cv2.cvtColor(wsi, cv2.COLOR_GRAY2RGB)
else:
wsi = cv2.cvtColor(wsi, cv2.COLOR_BGR2RGB)
wsi = wsi.astype(np.float32) / max_pix_value
pad = {"top": 0, "bottom": 0, "left": 0, "right": 0}
if transform is None:
return wsi, pad
h_orig, w_orig = wsi.shape[:2]
augmented = transform(image=wsi)
tensor = augmented["image"]
h_new, w_new = tensor.shape[1], tensor.shape[2]
if h_new > h_orig:
diff_h = h_new - h_orig
pad["top"] = diff_h // 2
pad["bottom"] = diff_h - pad["top"]
if w_new > w_orig:
diff_w = w_new - w_orig
pad["left"] = diff_w // 2
pad["right"] = diff_w - pad["left"]
return tensor, pad
def create_gaussian_mask(
patch_size: int,
sigma: float,
device: str,
) -> torch.Tensor:
coords = torch.arange(patch_size, dtype=torch.float32, device=device)
coords -= (patch_size - 1) / 2.0
y, x = torch.meshgrid(coords, coords, indexing="ij")
return torch.exp(-(x**2 + y**2) / (2 * sigma**2))
def predict_density_map(
wsi_tensor: torch.Tensor,
loaded: LoadedModel,
inference_batch_size: int = 8,
) -> torch.Tensor:
_, _, height, width = wsi_tensor.shape
patch_size = loaded.patch_size
stride = loaded.stride
device = loaded.device
global_density = torch.zeros(
(1, loaded.num_classes, height, width), dtype=torch.float32, device=device
)
global_weight = torch.zeros_like(global_density)
gaussian = create_gaussian_mask(
patch_size=patch_size,
sigma=(patch_size // 2) // 3,
device=device,
).view(1, 1, patch_size, patch_size)
max_h = height - patch_size
max_w = width - patch_size
anchors = [
(y, x)
for y in list(range(0, max_h, stride)) + [max_h]
for x in list(range(0, max_w, stride)) + [max_w]
]
patches = [
wsi_tensor[:, :, y : y + patch_size, x : x + patch_size] for y, x in anchors
]
with torch.no_grad():
for start in range(0, len(patches), inference_batch_size):
batch = torch.cat(patches[start : start + inference_batch_size], dim=0).to(
device
)
preds = loaded.model(batch)
batch_anchors = anchors[start : start + inference_batch_size]
for pred, (y, x) in zip(preds, batch_anchors):
pred = pred.unsqueeze(0)
global_density[:, :, y : y + patch_size, x : x + patch_size] += (
pred * gaussian
)
global_weight[:, :, y : y + patch_size, x : x + patch_size] += gaussian
return global_density / (global_weight + 1e-8)
def unpad_density_map(
density: torch.Tensor,
pad: dict[str, int],
) -> torch.Tensor:
y_end = density.shape[2] - pad["bottom"]
x_end = density.shape[3] - pad["right"]
return density[:, :, pad["top"] : y_end, pad["left"] : x_end]
def extract_roi_mask_from_geojson(
geojson_path: str | Path,
image_shape: tuple[int, int],
roi_class: str = "OverallCA",
) -> np.ndarray:
with Path(geojson_path).open("r") as fh:
geojson = json.load(fh)
roi_geoms = []
for feature in geojson.get("features", []):
props = feature.get("properties", {})
object_class = props.get("classification", {}).get("name")
if object_class != roi_class:
continue
geom = shape(feature.get("geometry", {}))
if geom.is_valid:
roi_geoms.append(geom)
if not roi_geoms:
return np.zeros(image_shape, dtype=np.uint8)
return rasterio.features.rasterize([unary_union(roi_geoms)], out_shape=image_shape)
def sample_discrete_density_numpy(
density_mask: np.ndarray,
rng: np.random.Generator,
) -> np.ndarray:
height, width, channels = density_mask.shape
discrete = np.zeros((height, width, channels), dtype=np.int32)
clean_density = np.clip(density_mask, a_min=0.0, a_max=None)
for channel_idx in range(channels):
channel_map = clean_density[:, :, channel_idx]
total_mass = float(channel_map.sum())
if total_mass <= 1e-6:
continue
int_mass = int(total_mass)
extra_cell = 1 if rng.random() < total_mass - int_mass else 0
n_samples = int_mass + extra_cell
if n_samples == 0:
continue
pmf = (channel_map / total_mass).ravel()
pmf[-1] = 1.0 - pmf[:-1].sum()
pmf = np.clip(pmf, a_min=0.0, a_max=None)
pmf = pmf / pmf.sum()
discrete[:, :, channel_idx] = rng.multinomial(n_samples, pmf).reshape(
height, width
)
return discrete
def normalize_original_for_display(image_path: str | Path) -> np.ndarray:
image = cv2.imread(str(image_path), cv2.IMREAD_UNCHANGED)
if image.ndim == 2:
image = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB)
else:
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
image = image.astype(np.float32)
image -= image.min()
max_value = image.max()
if max_value > 0:
image /= max_value
return (image * 255).astype(np.uint8)
def colorize_density(density: np.ndarray) -> np.ndarray:
density = np.clip(density, 0, None)
if density.max() > density.min():
scaled = (density - density.min()) / (density.max() - density.min())
else:
scaled = np.zeros_like(density)
return (colormaps["viridis"](scaled)[..., :3] * 255).astype(np.uint8)
def normalize_density_channel(channel: np.ndarray) -> np.ndarray:
channel = np.clip(channel.astype(np.float32), 0, None)
positive = channel[channel > 0]
if positive.size == 0:
return np.zeros_like(channel, dtype=np.float32)
lo = float(np.percentile(positive, 2))
hi = float(np.percentile(positive, 99))
if hi <= lo:
hi = float(np.max(positive))
lo = 0.0
if hi <= lo:
return np.clip(channel, 0.0, 1.0)
normalized = (channel - lo) / (hi - lo)
normalized[channel <= 0] = 0.0
return np.clip(normalized, 0.0, 1.0)
def colorize_sampled(sampled: np.ndarray, color: np.ndarray) -> np.ndarray:
image = np.full((*sampled.shape, 3), 255, dtype=np.uint8)
mask = sampled > 0
image[mask] = color
return image
def build_combined_points(sampled: np.ndarray) -> np.ndarray:
combined = np.full((*sampled.shape[:2], 3), 255, dtype=np.uint8)
for class_idx, color in enumerate(CLASS_COLORS):
combined[sampled[:, :, class_idx] > 0] = color
return combined
def build_combined_density_overlay(
image: np.ndarray,
density: np.ndarray,
alpha: float = 0.72,
) -> np.ndarray:
overlay = np.zeros((*density.shape[:2], 3), dtype=np.float32)
for class_idx, color in enumerate(CLASS_COLORS):
normalized = normalize_density_channel(density[:, :, class_idx])
color_arr = color.astype(np.float32) / 255.0
overlay += normalized[..., np.newaxis] * color_arr
overlay = np.clip(overlay, 0.0, 1.0)
base = image.astype(np.float32) / 255.0
blended = base * (1.0 - alpha) + overlay * alpha
blended = np.clip(blended, 0.0, 1.0)
return np.rint(blended * 255.0).astype(np.uint8)
def run_demo_inference(
image_path: str | Path,
geojson_path: str | Path | None = None,
use_roi: bool = True,
seed: int = 42,
) -> dict[str, Any]:
loaded = load_demo_model()
wsi_tensor, pad = load_low_res_wsi(
image_path=image_path,
max_pix_value=loaded.max_pix_value,
transform=loaded.transform,
)
wsi_tensor = wsi_tensor.unsqueeze(0).to(loaded.device)
density = predict_density_map(wsi_tensor, loaded=loaded)
density = unpad_density_map(density, pad)
density_array = density.squeeze(0).permute(1, 2, 0).cpu().numpy()
roi_mask = None
pred_array = density_array
if use_roi and geojson_path is not None:
roi_mask = extract_roi_mask_from_geojson(
geojson_path=geojson_path,
image_shape=density_array.shape[:2],
)
pred_array = density_array * roi_mask[..., np.newaxis]
sampled = sample_discrete_density_numpy(pred_array, rng=np.random.default_rng(seed))
density_maps = [
colorize_density(pred_array[:, :, i]) for i in range(loaded.num_classes)
]
sampled_maps = [
colorize_sampled(sampled[:, :, i], CLASS_COLORS[i])
for i in range(loaded.num_classes)
]
counts = []
for idx, class_name in enumerate(CLASS_NAMES[: loaded.num_classes]):
counts.append(
{
"class": class_name,
"density_sum": round(float(pred_array[:, :, idx].sum()), 2),
"sampled_count": int(sampled[:, :, idx].sum()),
}
)
original = normalize_original_for_display(image_path)
return {
"original": original,
"roi_mask": roi_mask,
"combined_density": build_combined_density_overlay(original, pred_array),
"density_maps": density_maps,
"sampled_maps": sampled_maps,
"combined_points": build_combined_points(sampled),
"counts": counts,
}