Download 8class-WSI-quantify.py from haozhongma/WSI_Quantify: direct link, hf CLI and curl.
- Browser
- Download file 37 kB
-
https://huggingface.co/haozhongma/WSI_Quantify/resolve/main/8class-WSI-quantify.py
- Command line
-
hf download hf://haozhongma/WSI_Quantify/8class-WSI-quantify.py
-
curl -L -o 8class-WSI-quantify.py https://huggingface.co/haozhongma/WSI_Quantify/resolve/main/8class-WSI-quantify.py
37 kB
| import openslide | |
| import os | |
| import torch | |
| import torch.nn.functional as F | |
| import torchvision.transforms as transforms | |
| import torchvision.models as models | |
| from torch import nn | |
| from torch.utils.data import Dataset, DataLoader | |
| import numpy as np | |
| from PIL import Image | |
| from tqdm import tqdm | |
| from collections import OrderedDict, defaultdict | |
| import argparse | |
| import csv | |
| import scipy.ndimage | |
| # --- Constants --- | |
| class_map = { | |
| 'Adipose': 0, 'Background': 1, 'Debris': 2, 'Lymphocytes': 3, | |
| 'Muscle': 4, 'Normal': 5, 'Stroma': 6, 'Tumour': 7, | |
| 'Tumor_Relate_Lymphocytes': 8 | |
| } | |
| label_to_name = {v: k for k, v in class_map.items()} | |
| EXPECTED_CONCH_CLASSES = ["ADI", "BAC", "DEB", "LYM", "MUS", "NOR", "STR", "TUM"] | |
| NUM_MODEL_CLASSES = len(EXPECTED_CONCH_CLASSES) | |
| DEFAULT_RESNET50_MODEL_PATH = os.environ.get("RESNET50_MODEL_PATH", "checkpoints/8class_resnet50.pth") | |
| DEFAULT_CONCH_MODEL_DIR = os.environ.get("CONCH_MODEL_DIR", "models/CONCH") | |
| DEFAULT_CONCH_HEAD_PATH = os.environ.get("CONCH_HEAD_PATH", "checkpoints/conch_mlp_probe.pt") | |
| DEFAULT_VIT_B_16_MODEL_PATH = os.environ.get("VIT_B_16_MODEL_PATH", "checkpoints/8class_vit_b_16.pth") | |
| LABEL_COLORS = { | |
| 0: (246, 227, 109), # Adipose | |
| 1: (255, 255, 255), # Background | |
| 2: (27, 121, 175), # Debris | |
| 8: (164, 53, 219), # Tumor_Relate_Lymphocytes | |
| 4: (202, 234, 194), # Muscle | |
| 5: (187, 225, 115), # Normal | |
| 6: (101, 194, 164), # Stroma | |
| 7: (227, 27, 30), # Tumour | |
| 3: (211, 186, 217) # Lymphocytes | |
| } | |
| # --- Model Definition --- | |
| class ModifiedResNet50(models.ResNet): | |
| def __init__(self, num_classes=8, dropout_probability=0.5): | |
| super(ModifiedResNet50, self).__init__(block=models.resnet.Bottleneck, layers=[3, 4, 6, 3]) | |
| self.fc = nn.Sequential( | |
| nn.Dropout(dropout_probability), | |
| nn.Linear(self.fc.in_features, num_classes) | |
| ) | |
| class ConchImageEncoder(nn.Module): | |
| def __init__(self, model): | |
| super().__init__() | |
| self.model = model | |
| def forward(self, images): | |
| return self.model.encode_image(images, proj_contrast=False, normalize=False) | |
| def build_mlp_head(feature_dim, num_classes, hidden_dim=512, dropout=0.2): | |
| return nn.Sequential( | |
| nn.Linear(feature_dim, hidden_dim), | |
| nn.GELU(), | |
| nn.Dropout(dropout), | |
| nn.Linear(hidden_dim, num_classes), | |
| ) | |
| def resolve_conch_weights(model_dir): | |
| weights_path = os.path.join(model_dir, "pytorch_model.bin") | |
| if not os.path.exists(weights_path): | |
| raise FileNotFoundError(f"CONCH weights not found: {weights_path}") | |
| return weights_path | |
| def load_resnet50_classifier(model_path, device): | |
| if not os.path.exists(model_path): | |
| raise FileNotFoundError(f"ResNet50 checkpoint not found: {model_path}") | |
| model = ModifiedResNet50(num_classes=8) | |
| state_dict = torch.load(model_path, map_location=device, weights_only=False) | |
| new_state_dict = OrderedDict((k.replace('module.', ''), v) for k, v in state_dict.items()) | |
| model.load_state_dict(new_state_dict) | |
| model.to(device) | |
| model.eval() | |
| print(f"Loaded ResNet50 classifier: checkpoint={model_path}") | |
| return model | |
| def normalize_state_dict_for_model(state_dict, model): | |
| if isinstance(state_dict, dict) and "state_dict" in state_dict: | |
| state_dict = state_dict["state_dict"] | |
| model_is_parallel = isinstance(model, nn.DataParallel) | |
| has_module_prefix = any(key.startswith("module.") for key in state_dict.keys()) | |
| if model_is_parallel and not has_module_prefix: | |
| return OrderedDict((f"module.{key}", value) for key, value in state_dict.items()) | |
| if not model_is_parallel and has_module_prefix: | |
| return OrderedDict((key.replace("module.", "", 1), value) for key, value in state_dict.items()) | |
| return state_dict | |
| def load_vit_b_16_classifier(model_path, device): | |
| if not os.path.exists(model_path): | |
| raise FileNotFoundError(f"ViT-B/16 checkpoint not found: {model_path}") | |
| model = models.vit_b_16(weights=None) | |
| model.heads.head = nn.Linear(model.heads.head.in_features, 8) | |
| checkpoint = torch.load(model_path, map_location=device, weights_only=False) | |
| state_dict = normalize_state_dict_for_model(checkpoint, model) | |
| model.load_state_dict(state_dict) | |
| model.to(device) | |
| model.eval() | |
| print(f"Loaded ViT-B/16 classifier: checkpoint={model_path}") | |
| return model | |
| def load_conch_classifier(conch_model_dir, head_path, device): | |
| from conch.open_clip_custom import create_model_from_pretrained | |
| if not os.path.exists(head_path): | |
| raise FileNotFoundError(f"CONCH MLP head checkpoint not found: {head_path}") | |
| conch_weights_path = resolve_conch_weights(conch_model_dir) | |
| conch_model, preprocess = create_model_from_pretrained( | |
| "conch_ViT-B-16", | |
| conch_weights_path, | |
| ) | |
| encoder = ConchImageEncoder(conch_model).to(device) | |
| encoder.eval() | |
| checkpoint = torch.load(head_path, map_location="cpu", weights_only=False) | |
| state_dict = checkpoint.get("state_dict", checkpoint) | |
| feature_dim = int(checkpoint.get("feature_dim", state_dict["0.weight"].shape[1])) | |
| hidden_dim = int(checkpoint.get("mlp_hidden_dim", state_dict["0.weight"].shape[0])) | |
| num_classes = int(checkpoint.get("num_classes", state_dict["3.weight"].shape[0])) | |
| dropout = float(checkpoint.get("mlp_dropout", 0.2)) | |
| class_names = checkpoint.get("class_names", EXPECTED_CONCH_CLASSES) | |
| if list(class_names) != EXPECTED_CONCH_CLASSES: | |
| raise ValueError(f"Unexpected CONCH class order: {class_names}; expected {EXPECTED_CONCH_CLASSES}") | |
| if num_classes != 8: | |
| raise ValueError(f"Expected an 8-class CONCH head, got num_classes={num_classes}") | |
| head = build_mlp_head(feature_dim, num_classes, hidden_dim, dropout).to(device) | |
| head.load_state_dict(state_dict) | |
| head.eval() | |
| normalize_features = bool(checkpoint.get("normalize_features", False)) | |
| print( | |
| "Loaded CONCH classifier: " | |
| f"encoder={conch_weights_path}, head={head_path}, " | |
| f"feature_dim={feature_dim}, hidden_dim={hidden_dim}, " | |
| f"normalize_features={normalize_features}" | |
| ) | |
| return encoder, head, preprocess, normalize_features | |
| # --- Optimized Dataset for Parallel I/O --- | |
| class WSIPatchDataset(Dataset): | |
| def __init__(self, slide_path, coords, tile_size=224, preprocess=None, exact_white_filter=False, white_threshold=230, white_ratio=0.90): | |
| self.slide_path = slide_path | |
| self.coords = coords | |
| self.tile_size = tile_size | |
| self.preprocess = preprocess or transforms.Compose([ | |
| transforms.ToTensor(), | |
| transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) | |
| ]) | |
| self.exact_white_filter = exact_white_filter | |
| self.white_threshold = white_threshold | |
| self.white_ratio = white_ratio | |
| self.dummy_tensor = self.preprocess(Image.new("RGB", (tile_size, tile_size), (255, 255, 255))) | |
| self._slide = None # Worker-specific slide handle. | |
| def _get_slide(self): | |
| # OpenSlide objects cannot be pickled, so each worker needs its own instance. | |
| if self._slide is None: | |
| self._slide = openslide.OpenSlide(self.slide_path) | |
| return self._slide | |
| def __len__(self): | |
| return len(self.coords) | |
| def __getitem__(self, idx): | |
| x, y = self.coords[idx] | |
| slide = self._get_slide() | |
| img = slide.read_region((x, y), 0, (self.tile_size, self.tile_size)).convert('RGB') | |
| if self.exact_white_filter: | |
| # Optional exact white check. This preserves the original behavior but costs CPU time. | |
| img_np = np.asarray(img) | |
| white_ratio = np.mean(np.all(img_np >= self.white_threshold, axis=-1)) | |
| if white_ratio > self.white_ratio: | |
| return torch.zeros_like(self.dummy_tensor), 1, x, y | |
| return self.preprocess(img), 0, x, y | |
| return self.preprocess(img), x, y | |
| # --- Core Processing --- | |
| def _parse_float_property(properties, keys): | |
| for key in keys: | |
| value = properties.get(key) | |
| if value is None: | |
| continue | |
| try: | |
| return float(value) | |
| except (TypeError, ValueError): | |
| continue | |
| return None | |
| def get_slide_mpp(slide): | |
| mpp_x_keys = [ | |
| getattr(openslide, 'PROPERTY_NAME_MPP_X', 'openslide.mpp-x'), | |
| 'openslide.mpp-x', | |
| 'aperio.MPP', | |
| ] | |
| mpp_y_keys = [ | |
| getattr(openslide, 'PROPERTY_NAME_MPP_Y', 'openslide.mpp-y'), | |
| 'openslide.mpp-y', | |
| 'aperio.MPP', | |
| ] | |
| mpp_x = _parse_float_property(slide.properties, mpp_x_keys) | |
| mpp_y = _parse_float_property(slide.properties, mpp_y_keys) | |
| if mpp_x is not None and mpp_y is not None: | |
| return (mpp_x + mpp_y) / 2.0 | |
| if mpp_x is not None: | |
| return mpp_x | |
| return mpp_y | |
| def build_all_coords(width, height, tile_size, step): | |
| # The output is a spatial grid with one cell per stride, not one cell per | |
| # tile origin. A tile therefore covers every grid cell intersecting its | |
| # tile_size x tile_size footprint (2 x 2 cells for tile_size=224, step=112). | |
| output_rows = int(np.ceil(height / step)) | |
| output_cols = int(np.ceil(width / step)) | |
| tile_rows = max(0, (height - tile_size) // step + 1) | |
| tile_cols = max(0, (width - tile_size) // step + 1) | |
| coords = [(c * step, r * step) for r in range(tile_rows) for c in range(tile_cols)] | |
| return coords, output_rows, output_cols | |
| def build_tissue_coords( | |
| slide, | |
| tile_size, | |
| step, | |
| mask_level=-1, | |
| white_threshold=230, | |
| tissue_ratio_threshold=0.05, | |
| close_radius=2, | |
| min_downsample=4.0, | |
| max_mask_pixels=25_000_000, | |
| ): | |
| """ | |
| Fast low-resolution tissue prefilter. | |
| This function is intentionally fail-safe: if the slide has no usable | |
| downsampled level, if the chosen level is too large, or if OpenSlide fails | |
| to read that level, it returns all grid coordinates instead of crashing. | |
| That keeps the script usable for SVS/TIF/MRXS files with incomplete pyramid | |
| metadata or problematic low-resolution levels. | |
| """ | |
| width, height = slide.level_dimensions[0] | |
| all_coords, n_rows, n_cols = build_all_coords(width, height, tile_size, step) | |
| try: | |
| level_count = int(slide.level_count) | |
| if level_count <= 1 and mask_level != 0: | |
| print( | |
| "Tissue prefilter skipped: slide has no downsampled pyramid level. " | |
| "Processing all grid tiles instead." | |
| ) | |
| return all_coords, n_rows, n_cols | |
| if mask_level < 0: | |
| # Choose the first level whose downsample is large enough. | |
| # This avoids accidentally reading the whole level-0 WSI as a mask. | |
| chosen_level = None | |
| for level_idx in range(1, level_count): | |
| if float(slide.level_downsamples[level_idx]) >= float(min_downsample): | |
| chosen_level = level_idx | |
| break | |
| if chosen_level is None: | |
| print( | |
| f"Tissue prefilter skipped: no level with downsample >= {min_downsample}. " | |
| "Processing all grid tiles instead." | |
| ) | |
| return all_coords, n_rows, n_cols | |
| mask_level = chosen_level | |
| else: | |
| mask_level = max(0, min(int(mask_level), level_count - 1)) | |
| low_w, low_h = slide.level_dimensions[mask_level] | |
| downsample = float(slide.level_downsamples[mask_level]) | |
| if mask_level == 0 and downsample < float(min_downsample): | |
| print( | |
| "Tissue prefilter skipped: selected mask level is level 0, " | |
| "which would require reading the full-resolution WSI. " | |
| "Processing all grid tiles instead." | |
| ) | |
| return all_coords, n_rows, n_cols | |
| mask_pixels = int(low_w) * int(low_h) | |
| if mask_pixels > int(max_mask_pixels): | |
| print( | |
| "Tissue prefilter skipped: selected mask level is too large " | |
| f"({low_w}x{low_h}={mask_pixels:,} pixels > {max_mask_pixels:,}). " | |
| "Processing all grid tiles instead." | |
| ) | |
| return all_coords, n_rows, n_cols | |
| low_img = slide.read_region((0, 0), mask_level, (low_w, low_h)).convert("RGB") | |
| low_np = np.asarray(low_img) | |
| # Tissue is anything not close to white in at least one channel. | |
| tissue_mask = np.any(low_np < white_threshold, axis=-1) | |
| if close_radius > 0 and tissue_mask.any(): | |
| structure = np.ones((2 * close_radius + 1, 2 * close_radius + 1), dtype=bool) | |
| tissue_mask = scipy.ndimage.binary_closing(tissue_mask, structure=structure) | |
| tissue_mask = scipy.ndimage.binary_dilation(tissue_mask, structure=structure) | |
| coords = [] | |
| for x, y in all_coords: | |
| x0 = max(0, int(x / downsample)) | |
| y0 = max(0, int(y / downsample)) | |
| x1 = min(low_w, int(np.ceil((x + tile_size) / downsample))) | |
| y1 = min(low_h, int(np.ceil((y + tile_size) / downsample))) | |
| patch_mask = tissue_mask[y0:y1, x0:x1] | |
| if patch_mask.size > 0 and float(patch_mask.mean()) >= tissue_ratio_threshold: | |
| coords.append((x, y)) | |
| skipped = len(all_coords) - len(coords) | |
| print( | |
| "Tissue prefilter: " | |
| f"mask_level={mask_level}, downsample={downsample:.1f}, " | |
| f"kept={len(coords)}/{len(all_coords)} tiles, skipped={skipped} blank-like tiles" | |
| ) | |
| return coords, n_rows, n_cols | |
| except Exception as exc: | |
| print( | |
| "Tissue prefilter failed and was skipped: " | |
| f"{type(exc).__name__}: {exc}. Processing all grid tiles instead." | |
| ) | |
| return all_coords, n_rows, n_cols | |
| def make_dataloader(dataset, batch_size, num_workers, pin_memory, prefetch_factor): | |
| kwargs = dict( | |
| batch_size=batch_size, | |
| shuffle=False, | |
| num_workers=num_workers, | |
| pin_memory=pin_memory, | |
| ) | |
| if num_workers > 0: | |
| kwargs["persistent_workers"] = True | |
| kwargs["prefetch_factor"] = prefetch_factor | |
| return DataLoader(dataset, **kwargs) | |
| def run_model_forward(model_type, imgs, model, encoder, head, normalize_features): | |
| if model_type == "conch": | |
| features = encoder(imgs) | |
| if isinstance(features, (tuple, list)): | |
| features = features[0] | |
| features = features.float() | |
| if normalize_features: | |
| features = F.normalize(features, p=2, dim=1) | |
| return head(features) | |
| return model(imgs) | |
| def accumulate_tile_probabilities( | |
| probability_sums, | |
| coverage_counts, | |
| tile_probabilities, | |
| xs, | |
| ys, | |
| tile_size, | |
| step, | |
| ): | |
| """Accumulate each tile's class probabilities over its spatial footprint.""" | |
| tile_probabilities = np.asarray(tile_probabilities, dtype=np.float32) | |
| xs = np.asarray(xs, dtype=np.int64) | |
| ys = np.asarray(ys, dtype=np.int64) | |
| if tile_probabilities.ndim != 2 or tile_probabilities.shape[1] != NUM_MODEL_CLASSES: | |
| raise ValueError( | |
| f"Expected tile probabilities with shape (N, {NUM_MODEL_CLASSES}), " | |
| f"got {tile_probabilities.shape}." | |
| ) | |
| if tile_probabilities.shape[0] != xs.size or xs.size != ys.size: | |
| raise ValueError("Tile probabilities and coordinates must contain the same number of tiles.") | |
| row_origins = ys // step | |
| col_origins = xs // step | |
| grid_span = int(np.ceil(tile_size / step)) | |
| n_rows, n_cols = coverage_counts.shape | |
| # np.add.at is required because several tiles can address the same output | |
| # cell in one batch. Ordinary advanced-index += may lose repeated updates. | |
| for row_offset in range(grid_span): | |
| rows = row_origins + row_offset | |
| valid_rows = rows < n_rows | |
| for col_offset in range(grid_span): | |
| cols = col_origins + col_offset | |
| valid = valid_rows & (cols < n_cols) | |
| if not np.any(valid): | |
| continue | |
| np.add.at( | |
| probability_sums, | |
| (rows[valid], cols[valid]), | |
| tile_probabilities[valid], | |
| ) | |
| np.add.at(coverage_counts, (rows[valid], cols[valid]), 1) | |
| def fuse_probability_map(probability_sums, coverage_counts): | |
| """Average overlapping predictions and return one deterministic label per cell.""" | |
| label_matrix = np.full( | |
| coverage_counts.shape, | |
| class_map['Background'], | |
| dtype=np.int8, | |
| ) | |
| covered = coverage_counts > 0 | |
| if not np.any(covered): | |
| return label_matrix | |
| mean_probabilities = ( | |
| probability_sums[covered] | |
| / coverage_counts[covered, np.newaxis].astype(np.float32) | |
| ) | |
| label_matrix[covered] = np.argmax(mean_probabilities, axis=1).astype(np.int8) | |
| return label_matrix | |
| def generate_label_matrix( | |
| slide_path, | |
| model_type, | |
| model_path, | |
| conch_model_dir, | |
| tile_size, | |
| step, | |
| gpu_id, | |
| batch_size, | |
| num_workers=8, | |
| prefetch_factor=4, | |
| use_tissue_prefilter=True, | |
| tissue_mask_level=-1, | |
| tissue_white_threshold=230, | |
| tissue_ratio_threshold=0.05, | |
| tissue_close_radius=2, | |
| tissue_min_downsample=4.0, | |
| tissue_max_mask_pixels=25_000_000, | |
| exact_white_filter=False, | |
| exact_white_ratio=0.90, | |
| amp=True, | |
| ): | |
| """ | |
| Runs inference and returns a fused, single-label spatial map of the WSI. | |
| The eight-class softmax vector from every tile is accumulated over all | |
| stride-grid cells covered by that tile. At each covered cell, accumulated | |
| probabilities are divided by the actual number of contributing tiles and | |
| the class with the highest mean probability becomes the unique label. | |
| Speed optimizations: | |
| 1) low-resolution tissue prefilter to reduce level-0 reads; | |
| 2) pinned memory + non_blocking GPU transfer; | |
| 3) torch.inference_mode; | |
| 4) AMP for all supported model backends; | |
| 5) configurable dataloader workers/prefetch. | |
| """ | |
| if step <= 0: | |
| raise ValueError("step must be positive.") | |
| slide = openslide.OpenSlide(slide_path) | |
| w, h = slide.level_dimensions[0] | |
| slide_mpp = get_slide_mpp(slide) | |
| if use_tissue_prefilter: | |
| coords, n_rows, n_cols = build_tissue_coords( | |
| slide, | |
| tile_size=tile_size, | |
| step=step, | |
| mask_level=tissue_mask_level, | |
| white_threshold=tissue_white_threshold, | |
| tissue_ratio_threshold=tissue_ratio_threshold, | |
| close_radius=tissue_close_radius, | |
| min_downsample=tissue_min_downsample, | |
| max_mask_pixels=tissue_max_mask_pixels, | |
| ) | |
| else: | |
| coords, n_rows, n_cols = build_all_coords(w, h, tile_size, step) | |
| print(f"Tissue prefilter disabled: processing all {len(coords)} tiles") | |
| probability_sums = np.zeros((n_rows, n_cols, NUM_MODEL_CLASSES), dtype=np.float32) | |
| coverage_counts = np.zeros((n_rows, n_cols), dtype=np.uint32) | |
| if len(coords) == 0: | |
| print("No tissue tiles found. Returning all-background label matrix.") | |
| label_matrix = fuse_probability_map(probability_sums, coverage_counts) | |
| return label_matrix, (w, h), step, slide_mpp | |
| device = torch.device(f"cuda:{gpu_id}" if torch.cuda.is_available() else "cpu") | |
| if device.type == "cuda": | |
| torch.backends.cudnn.benchmark = True | |
| try: | |
| torch.set_float32_matmul_precision("high") | |
| except Exception: | |
| pass | |
| model = None | |
| encoder = None | |
| head = None | |
| normalize_features = False | |
| if model_type == "conch": | |
| encoder, head, preprocess, normalize_features = load_conch_classifier( | |
| conch_model_dir, | |
| model_path, | |
| device, | |
| ) | |
| elif model_type == "resnet50": | |
| model = load_resnet50_classifier(model_path, device) | |
| preprocess = None | |
| elif model_type == "vit_b_16": | |
| model = load_vit_b_16_classifier(model_path, device) | |
| preprocess = None | |
| else: | |
| raise ValueError(f"Unsupported model_type: {model_type}") | |
| dataset = WSIPatchDataset( | |
| slide_path, | |
| coords, | |
| tile_size=tile_size, | |
| preprocess=preprocess, | |
| exact_white_filter=exact_white_filter, | |
| white_threshold=tissue_white_threshold, | |
| white_ratio=exact_white_ratio, | |
| ) | |
| pin_memory = device.type == "cuda" | |
| loader = make_dataloader( | |
| dataset, | |
| batch_size=batch_size, | |
| num_workers=num_workers, | |
| pin_memory=pin_memory, | |
| prefetch_factor=prefetch_factor, | |
| ) | |
| print( | |
| f"Start inference: tiles={len(coords)}, batch_size={batch_size}, " | |
| f"num_workers={num_workers}, amp={amp and device.type == 'cuda'}, exact_white_filter={exact_white_filter}" | |
| ) | |
| with torch.inference_mode(): | |
| if exact_white_filter: | |
| for imgs, is_white_flags, xs, ys in tqdm(loader, desc="Inference"): | |
| valid_mask = is_white_flags == 0 | |
| if not valid_mask.any(): | |
| continue | |
| imgs = imgs[valid_mask].to(device, non_blocking=True) | |
| with torch.amp.autocast(device_type=device.type, enabled=amp and device.type == "cuda"): | |
| outputs = run_model_forward(model_type, imgs, model, encoder, head, normalize_features) | |
| probabilities = torch.softmax(outputs.float(), dim=1).cpu().numpy() | |
| accumulate_tile_probabilities( | |
| probability_sums, | |
| coverage_counts, | |
| probabilities, | |
| xs[valid_mask].numpy(), | |
| ys[valid_mask].numpy(), | |
| tile_size, | |
| step, | |
| ) | |
| else: | |
| for imgs, xs, ys in tqdm(loader, desc="Inference"): | |
| imgs = imgs.to(device, non_blocking=True) | |
| with torch.amp.autocast(device_type=device.type, enabled=amp and device.type == "cuda"): | |
| outputs = run_model_forward(model_type, imgs, model, encoder, head, normalize_features) | |
| probabilities = torch.softmax(outputs.float(), dim=1).cpu().numpy() | |
| accumulate_tile_probabilities( | |
| probability_sums, | |
| coverage_counts, | |
| probabilities, | |
| xs.numpy(), | |
| ys.numpy(), | |
| tile_size, | |
| step, | |
| ) | |
| label_matrix = fuse_probability_map(probability_sums, coverage_counts) | |
| covered_cells = int(np.count_nonzero(coverage_counts)) | |
| max_coverage = int(coverage_counts.max(initial=0)) | |
| print( | |
| "Probability fusion: " | |
| f"covered_cells={covered_cells}/{coverage_counts.size}, " | |
| f"max_contributing_tiles={max_coverage}, output_grid={n_cols}x{n_rows}" | |
| ) | |
| try: | |
| slide.close() | |
| except Exception: | |
| pass | |
| return label_matrix, (w, h), step, slide_mpp | |
| def remove_small_components(binary_mask, min_size): | |
| if min_size <= 1 or not binary_mask.any(): | |
| return binary_mask | |
| component_matrix, component_count = scipy.ndimage.label(binary_mask) | |
| if component_count == 0: | |
| return binary_mask | |
| component_sizes = np.bincount(component_matrix.ravel()) | |
| keep_components = component_sizes >= min_size | |
| keep_components[0] = False | |
| return keep_components[component_matrix] | |
| def define_tumor_related_lymphocytes( | |
| label_matrix, | |
| step, | |
| mpp, | |
| radius_um=500.0, | |
| min_tumor_component_tiles=20, | |
| tumor_closing_radius=1, | |
| ): | |
| """ | |
| Defines Tumor_Relate_Lymphocytes by physical proximity to reliable Tumour regions. | |
| """ | |
| if mpp <= 0: | |
| raise ValueError("mpp must be positive.") | |
| if radius_um <= 0: | |
| raise ValueError("radius_um must be positive.") | |
| new_matrix = label_matrix.copy() | |
| new_matrix[new_matrix == class_map['Tumor_Relate_Lymphocytes']] = class_map['Lymphocytes'] | |
| tumor_mask = new_matrix == class_map['Tumour'] | |
| lymphocyte_mask = new_matrix == class_map['Lymphocytes'] | |
| lymphocyte_count = int(np.sum(lymphocyte_mask)) | |
| reliable_tumor_mask = remove_small_components(tumor_mask, min_tumor_component_tiles) | |
| if tumor_closing_radius > 0 and reliable_tumor_mask.any(): | |
| kernel_size = 2 * tumor_closing_radius + 1 | |
| structure = np.ones((kernel_size, kernel_size), dtype=bool) | |
| reliable_tumor_mask = scipy.ndimage.binary_closing(reliable_tumor_mask, structure=structure) | |
| reliable_tumor_mask = remove_small_components(reliable_tumor_mask, min_tumor_component_tiles) | |
| reliable_tumor_count = int(np.sum(reliable_tumor_mask)) | |
| if reliable_tumor_count == 0: | |
| print("No reliable Tumour region found; Tumor_Relate_Lymphocytes count is 0.") | |
| return new_matrix | |
| step_um = step * mpp | |
| radius_steps = max(1, int(np.ceil(radius_um / step_um))) | |
| distance_to_tumor_steps = scipy.ndimage.distance_transform_edt(~reliable_tumor_mask) | |
| tumor_related_mask = lymphocyte_mask & (distance_to_tumor_steps <= radius_steps) | |
| new_matrix[tumor_related_mask] = class_map['Tumor_Relate_Lymphocytes'] | |
| tumor_related_count = int(np.sum(tumor_related_mask)) | |
| print( | |
| "Tumor_Relate_Lymphocytes: " | |
| f"{tumor_related_count}/{lymphocyte_count} Lymphocytes within " | |
| f"{radius_um:.1f} um ({radius_steps} grid steps) of reliable Tumour; " | |
| f"mpp={mpp:.4f}, step_um={step_um:.2f}, reliable_tumor_tiles={reliable_tumor_count}" | |
| ) | |
| return new_matrix | |
| def create_vis_from_matrix(label_matrix, original_dim, step, scale_percent=10): | |
| """ | |
| Vectorized visualization. | |
| Map label indices directly to RGB colors using numpy fancy indexing. | |
| """ | |
| h_grid, w_grid = label_matrix.shape | |
| # 1. Create a color palette array (max_label + 1, 3) | |
| max_label = max(LABEL_COLORS.keys()) | |
| palette = np.zeros((max_label + 1, 3), dtype=np.uint8) | |
| for lbl, color in LABEL_COLORS.items(): | |
| palette[lbl] = color | |
| # 2. Map matrix to RGB image instantly | |
| # This replaces the loop of drawing rectangles | |
| vis_array = palette[label_matrix] | |
| # 3. Convert to Image | |
| vis_image = Image.fromarray(vis_array) | |
| # 4. Resize to target size | |
| # Note: label_matrix is already "downsampled" by step size. | |
| # Original logic: Image size = Original Dim / Scale | |
| target_w = original_dim[0] // scale_percent | |
| target_h = original_dim[1] // scale_percent | |
| # Use Nearest neighbor to keep sharp squares, or Bilinear/Lanczos for smooth look | |
| # Usually for tile maps, Nearest is more accurate to the data | |
| vis_image = vis_image.resize((target_w, target_h), Image.Resampling.NEAREST) | |
| return vis_image | |
| def safe_divide(numerator, denominator): | |
| return numerator / denominator if denominator != 0 else 0.0 | |
| def build_count_metrics(wsi_name, model_type, counts_dict, step, mpp): | |
| class_counts = { | |
| class_name: int(counts_dict.get(class_idx, 0)) | |
| for class_name, class_idx in class_map.items() | |
| } | |
| stroma_count = class_counts["Stroma"] | |
| lymphocytes_count = class_counts["Lymphocytes"] | |
| tumor_lymphocyte_count = class_counts["Tumor_Relate_Lymphocytes"] | |
| lsr = safe_divide( | |
| lymphocytes_count + tumor_lymphocyte_count, | |
| stroma_count + lymphocytes_count + tumor_lymphocyte_count, | |
| ) | |
| new_lsr = safe_divide( | |
| tumor_lymphocyte_count, | |
| stroma_count + tumor_lymphocyte_count, | |
| ) | |
| row = { | |
| "WSI_name": wsi_name, | |
| "model_type": model_type, | |
| "total_count": sum(class_counts.values()), | |
| "grid_cell_area_mm2": ((step * mpp) / 1000.0) ** 2, | |
| } | |
| for class_name in class_map: | |
| row[f"{class_name}_count"] = class_counts[class_name] | |
| row[f"{class_name}_area_mm2"] = ( | |
| class_counts[class_name] * row["grid_cell_area_mm2"] | |
| ) | |
| row["LSR"] = lsr | |
| row["NEW_LSR"] = new_lsr | |
| return row | |
| def save_metrics_csv(metrics_path, metrics_row): | |
| fieldnames = [ | |
| "WSI_name", | |
| "model_type", | |
| "total_count", | |
| "grid_cell_area_mm2", | |
| *[f"{class_name}_count" for class_name in class_map], | |
| *[f"{class_name}_area_mm2" for class_name in class_map], | |
| "LSR", | |
| "NEW_LSR", | |
| ] | |
| with open(metrics_path, "w", newline="") as csv_file: | |
| writer = csv.DictWriter(csv_file, fieldnames=fieldnames) | |
| writer.writeheader() | |
| writer.writerow(metrics_row) | |
| def main(): | |
| parser = argparse.ArgumentParser(description='Optimized WSI Processing') | |
| parser.add_argument('--path', type=str, required=True) | |
| parser.add_argument('--out', type=str, required=True) | |
| parser.add_argument('--gpu_id', type=int, default=0) | |
| parser.add_argument('--model_type', type=str, default='vit_b_16', choices=['resnet50', 'resnet5', 'conch', 'vit_b_16'], | |
| help='Tile classifier backend. "resnet5" is accepted as an alias for resnet50.') | |
| parser.add_argument('--model', type=str, default=None, | |
| help='Checkpoint path for the selected model_type. Defaults to the ViT-B/16 checkpoint, CONCH MLP head, or ResNet50 checkpoint.') | |
| parser.add_argument('--conch_model_dir', type=str, default=DEFAULT_CONCH_MODEL_DIR, | |
| help='Directory containing local CONCH pytorch_model.bin weights.') | |
| parser.add_argument('--batch_size', type=int, default=None, | |
| help='Inference batch size. Defaults to 256 for all model types.') | |
| parser.add_argument('--step', '--step_size', dest='step_size', type=int, default=None, | |
| help='Pixel stride between adjacent tiles. Default is tile_size // 2.') | |
| parser.add_argument('--trl_radius_um', type=float, default=500.0, | |
| help='Physical distance from Tumour used to define Tumor_Relate_Lymphocytes.') | |
| parser.add_argument('--mpp', type=float, default=None, | |
| help='Override microns-per-pixel. If omitted, read from slide metadata.') | |
| parser.add_argument('--default_mpp', type=float, default=0.25, | |
| help='Fallback microns-per-pixel when slide metadata has no MPP.') | |
| parser.add_argument('--min_tumor_component_tiles', type=int, default=20, | |
| help='Remove Tumour components smaller than this many grid tiles before TRL assignment.') | |
| parser.add_argument('--tumor_closing_radius', type=int, default=1, | |
| help='Morphological closing radius, in grid steps, for reliable Tumour mask.') | |
| parser.add_argument('--tile_size', type=int, default=224, | |
| help='Tile size in pixels. Keep 224 unless the model was trained with another size.') | |
| parser.add_argument('--num_workers', type=int, default=8, | |
| help='Number of DataLoader workers for parallel OpenSlide reading.') | |
| parser.add_argument('--prefetch_factor', type=int, default=4, | |
| help='Number of batches prefetched per worker when num_workers > 0.') | |
| parser.add_argument('--disable_tissue_prefilter', action='store_true', | |
| help='Disable low-resolution tissue prefilter and process all grid tiles.') | |
| parser.add_argument('--tissue_mask_level', type=int, default=-1, | |
| help='OpenSlide level used for tissue prefilter. -1 chooses automatically.') | |
| parser.add_argument('--tissue_white_threshold', type=int, default=230, | |
| help='RGB threshold used by tissue prefilter; pixels below this are considered tissue-like.') | |
| parser.add_argument('--tissue_ratio_threshold', type=float, default=0.05, | |
| help='Minimum low-resolution tissue fraction required to keep a tile.') | |
| parser.add_argument('--tissue_close_radius', type=int, default=2, | |
| help='Morphological close/dilation radius for low-resolution tissue mask.') | |
| parser.add_argument('--tissue_min_downsample', type=float, default=4.0, | |
| help='Minimum downsample required for using a pyramid level as tissue mask. Prevents reading full-resolution WSI as mask.') | |
| parser.add_argument('--tissue_max_mask_pixels', type=int, default=25000000, | |
| help='Maximum number of pixels allowed for the tissue-mask level. Larger mask levels are skipped safely.') | |
| parser.add_argument('--exact_white_filter', action='store_true', | |
| help='Run the original exact white-tile check after reading selected level-0 patches. More faithful but slower.') | |
| parser.add_argument('--exact_white_ratio', type=float, default=0.90, | |
| help='White-pixel ratio threshold for --exact_white_filter.') | |
| parser.add_argument('--no_amp', action='store_true', | |
| help='Disable AMP mixed precision inference.') | |
| parser.add_argument('--no_save_vis', action='store_true', | |
| help='Skip saving the visualization JPG. Useful when only metrics are needed.') | |
| parser.add_argument('--vis_scale_percent', type=int, default=10, | |
| help='Visualization output size divisor: original_dim / vis_scale_percent.') | |
| parser.add_argument('--jpg_quality', type=int, default=90, | |
| help='JPEG quality for visualization output.') | |
| args = parser.parse_args() | |
| if not os.path.exists(args.out): | |
| os.makedirs(args.out) | |
| pic_path = args.path | |
| WSI_name = os.path.splitext(os.path.basename(pic_path))[0] | |
| tile_size = args.tile_size | |
| step = args.step_size if args.step_size is not None else tile_size // 2 | |
| if step <= 0: | |
| raise ValueError("--step must be positive.") | |
| model_type = 'resnet50' if args.model_type == 'resnet5' else args.model_type | |
| model_path = args.model | |
| if model_path is None: | |
| if model_type == 'conch': | |
| model_path = DEFAULT_CONCH_HEAD_PATH | |
| elif model_type == 'vit_b_16': | |
| model_path = DEFAULT_VIT_B_16_MODEL_PATH | |
| else: | |
| model_path = DEFAULT_RESNET50_MODEL_PATH | |
| batch_size = args.batch_size | |
| if batch_size is None: | |
| batch_size = 256 | |
| print(f"Using model_type={model_type}, model={model_path}, batch_size={batch_size}") | |
| # 1. Generate Label Matrix (Batched & Parallel IO) | |
| label_matrix, original_dim, step, slide_mpp = generate_label_matrix( | |
| pic_path, | |
| model_type, | |
| model_path, | |
| args.conch_model_dir, | |
| tile_size, | |
| step, | |
| args.gpu_id, | |
| batch_size, | |
| num_workers=args.num_workers, | |
| prefetch_factor=args.prefetch_factor, | |
| use_tissue_prefilter=not args.disable_tissue_prefilter, | |
| tissue_mask_level=args.tissue_mask_level, | |
| tissue_white_threshold=args.tissue_white_threshold, | |
| tissue_ratio_threshold=args.tissue_ratio_threshold, | |
| tissue_close_radius=args.tissue_close_radius, | |
| tissue_min_downsample=args.tissue_min_downsample, | |
| tissue_max_mask_pixels=args.tissue_max_mask_pixels, | |
| exact_white_filter=args.exact_white_filter, | |
| exact_white_ratio=args.exact_white_ratio, | |
| amp=not args.no_amp, | |
| ) | |
| if args.mpp is not None: | |
| effective_mpp = args.mpp | |
| elif slide_mpp is not None: | |
| effective_mpp = slide_mpp | |
| else: | |
| effective_mpp = args.default_mpp | |
| print(f"Slide MPP not found; using default_mpp={effective_mpp}") | |
| # 2. Define tumor-related lymphocytes by proximity to reliable Tumour regions. | |
| label_matrix = define_tumor_related_lymphocytes( | |
| label_matrix, | |
| step=step, | |
| mpp=effective_mpp, | |
| radius_um=args.trl_radius_um, | |
| min_tumor_component_tiles=args.min_tumor_component_tiles, | |
| tumor_closing_radius=args.tumor_closing_radius, | |
| ) | |
| # 3. Count and Save | |
| unique, counts = np.unique(label_matrix, return_counts=True) | |
| counts_dict = dict(zip(unique, counts)) | |
| # Ensure all keys exist for the filename | |
| count_str_parts = [] | |
| for i in range(len(class_map)): | |
| count_str_parts.append(str(counts_dict.get(i, 0))) | |
| count_suffix = "_".join(count_str_parts) | |
| save_path = os.path.join(args.out, f"{WSI_name}_counts_{count_suffix}.jpg") | |
| metrics_path = os.path.splitext(save_path)[0] + "_metrics.csv" | |
| if not args.no_save_vis: | |
| vis_img = create_vis_from_matrix(label_matrix, original_dim, step, scale_percent=args.vis_scale_percent) | |
| vis_img.save(save_path, quality=args.jpg_quality) | |
| print(f"Saved visualization to {save_path}") | |
| else: | |
| print("Visualization saving skipped because --no_save_vis was set.") | |
| metrics_row = build_count_metrics( | |
| WSI_name, | |
| model_type, | |
| counts_dict, | |
| step=step, | |
| mpp=effective_mpp, | |
| ) | |
| save_metrics_csv(metrics_path, metrics_row) | |
| print(f"Saved metrics to {metrics_path}") | |
| print(f"LSR={metrics_row['LSR']:.8f}, NEW_LSR={metrics_row['NEW_LSR']:.8f}") | |
| if __name__ == '__main__': | |
| main() | |