| |
| |
|
|
| """ |
| RSNA Knee MRI — Standalone Folder Inference |
| ============================================= |
| |
| Chạy model đã train (best_model.pth / last_checkpoint.pth) trên BẤT KỲ |
| folder DICOM nào, không cần cấu trúc dữ liệu Kaggle (train.csv, |
| test_series.csv, ...). Toàn bộ hàm cần thiết (đọc DICOM, tiền xử lý, |
| kiến trúc model, load checkpoint, suy luận) đều nằm trong 1 file này. |
| |
| FOLDER STRUCTURE ĐƯỢC HỖ TRỢ (tự động phát hiện) |
| ------------------------------------------------- |
| 1) Một study duy nhất, các series là folder con trực tiếp: |
| |
| my_study/ |
| SeriesA/*.dcm |
| SeriesB/*.dcm |
| SeriesC/*.dcm |
| |
| 2) Nhiều study, mỗi study là 1 folder con chứa các series: |
| |
| many_studies/ |
| StudyInstanceUID_1/ |
| SeriesA/*.dcm |
| SeriesB/*.dcm |
| StudyInstanceUID_2/ |
| SeriesA/*.dcm |
| |
| 3) Lồng sâu hơn cũng được — bất kỳ folder nào chứa trực tiếp file .dcm |
| (hoặc file DICOM không có đuôi .dcm) sẽ được coi là 1 series; series |
| được gom theo folder cấp 1 ngay dưới --input-folder. |
| |
| METADATA (Anatomical_Plane / Fluid_Sensitive / Fat_Suppression) |
| ----------------------------------------------------------------- |
| Dữ liệu thi đấu gốc lấy 3 thông tin này từ train_series.csv / |
| test_series.csv. Với 1 folder bất kỳ, ta không có sẵn file đó, nên |
| script này TỰ SUY LUẬN (heuristic) từ DICOM header: |
| - Anatomical_Plane : từ ImageOrientationPatient (vector pháp tuyến |
| của mặt phẳng ảnh — trục nào chiếm ưu thế quyết định Sagittal / |
| Coronal / Axial). |
| - Fluid_Sensitive / Fat_Suppression : từ từ khoá trong |
| SeriesDescription / ProtocolName / SequenceName (vd "T2", "PD", |
| "STIR", "FS", "FATSAT" ...). |
| Đây CHỈ LÀ suy đoán — nếu bạn có metadata chính xác, hãy truyền vào |
| qua --metadata-csv với các cột: |
| SeriesFolder,Anatomical_Plane,Fluid_Sensitive,Fat_Suppression |
| (SeriesFolder = tên folder chứa trực tiếp các file .dcm của series đó). |
| Giá trị trong file này sẽ ĐÈ LÊN kết quả suy luận tự động. |
| |
| CÁCH DÙNG |
| --------- |
| Không dùng dòng lệnh / argparse. Sửa trực tiếp các đường dẫn trong khối |
| "USER CONFIG" ngay bên dưới (INPUT_FOLDER, CHECKPOINT_PATH, ...) rồi |
| chạy thẳng file: |
| |
| python infer_folder.py |
| |
| Yêu cầu thư viện: torch, timm, pydicom, pandas, numpy, (tuỳ chọn) opencv-python, tqdm. |
| """ |
|
|
| import os |
| import json |
| import math |
| import warnings |
| from pathlib import Path |
| from dataclasses import dataclass, field |
| from typing import Dict, List, Optional, Tuple |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| try: |
| import cv2 |
| except ImportError: |
| cv2 = None |
|
|
| import pydicom |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| try: |
| import timm |
| except ImportError as e: |
| raise ImportError( |
| "timm is required for this script. Install with: pip install timm" |
| ) from e |
|
|
| try: |
| from tqdm.auto import tqdm |
| except ImportError: |
| def tqdm(x, **kwargs): |
| return x |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| INPUT_FOLDER = "/path/to/your/test_folder" |
|
|
| |
| CHECKPOINT_PATH = "/path/to/best_model.pth" |
|
|
| |
| OUTPUT_CSV = "/path/to/predictions.csv" |
|
|
| |
| |
| METADATA_CSV = None |
|
|
| |
| DEVICE = None |
|
|
| |
| SAVE_ATTENTION = False |
|
|
| |
| LIMIT_STUDIES = None |
|
|
| |
| |
| BACKBONE_NAME = "convnextv2_tiny.fcmae_ft_in22k_in1k" |
| IMG_SIZE = 224 |
| EMBED_DIM = 384 |
| MAX_SERIES = 8 |
| CLIPS_PER_SERIES = 3 |
|
|
|
|
| |
| |
| |
|
|
| LABELS = [ |
| "ACL", |
| "MCL", |
| "Medial Meniscus", |
| "Lateral Meniscus", |
| "Medial OA", |
| "Lateral OA", |
| "PF OA", |
| "Effusion", |
| "Synovitis", |
| "Baker's", |
| "Contusion", |
| "Fracture", |
| ] |
|
|
| N_LABELS = len(LABELS) |
|
|
| PLANES = ["Sagittal", "Coronal", "Axial"] |
| PLANE2IDX = {p: i for i, p in enumerate(PLANES)} |
|
|
| |
| PLANE_PRIOR = np.array( |
| [ |
| [1.00, 0.65, 0.40], |
| [0.65, 1.00, 0.45], |
| [1.00, 0.90, 0.60], |
| [0.90, 1.00, 0.65], |
| [0.90, 1.00, 0.40], |
| [0.85, 1.00, 0.40], |
| [0.90, 0.55, 1.00], |
| [0.90, 0.85, 0.90], |
| [0.90, 0.85, 0.90], |
| [0.95, 0.70, 1.00], |
| [1.00, 0.90, 0.80], |
| [1.00, 0.95, 0.80], |
| ], |
| dtype=np.float32, |
| ) |
|
|
| FLUID_PRIOR = np.array( |
| [0.80, 0.75, 0.65, 0.65, 0.45, 0.45, 0.45, 0.95, 0.90, 0.90, 0.90, 0.85], |
| dtype=np.float32, |
| ) |
|
|
| FAT_SUPPRESSION_PRIOR = np.array( |
| [0.40, 0.40, 0.35, 0.35, 0.25, 0.25, 0.25, 0.65, 0.65, 0.70, 0.80, 0.75], |
| dtype=np.float32, |
| ) |
|
|
| IMAGENET_MEAN = torch.tensor([0.485, 0.456, 0.406], dtype=torch.float32).view(1, 3, 1, 1) |
| IMAGENET_STD = torch.tensor([0.229, 0.224, 0.225], dtype=torch.float32).view(1, 3, 1, 1) |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class InferCFG: |
| backbone_name: str = "convnextv2_tiny.fcmae_ft_in22k_in1k" |
| img_size: int = 224 |
| embed_dim: int = 384 |
| label_transformer_layers: int = 2 |
| label_transformer_heads: int = 8 |
| dropout: float = 0.15 |
|
|
| max_series: int = 8 |
| clips_per_series: int = 3 |
|
|
| percentile_low: float = 1.0 |
| percentile_high: float = 99.0 |
|
|
| amp: bool = True |
| device: str = field(default_factory=lambda: "cuda" if torch.cuda.is_available() else "cpu") |
|
|
|
|
| |
| |
| |
|
|
| def _safe_float(x, default: float = 0.0) -> float: |
| try: |
| return float(x) |
| except Exception: |
| return default |
|
|
|
|
| def is_dicom_file(path: Path) -> bool: |
| """Detect DICOM files that don't use the .dcm extension via the 'DICM' magic bytes.""" |
| try: |
| with open(path, "rb") as f: |
| f.seek(128) |
| return f.read(4) == b"DICM" |
| except Exception: |
| return False |
|
|
|
|
| def find_dicom_files(folder: Path) -> List[Path]: |
| """Return DICOM files directly inside `folder` (non-recursive).""" |
| if not folder.is_dir(): |
| return [] |
| files = [ |
| p for p in folder.iterdir() |
| if p.is_file() and p.suffix.lower() == ".dcm" |
| ] |
| if files: |
| return sorted(files) |
|
|
| |
| candidates = [ |
| p for p in folder.iterdir() |
| if p.is_file() and not p.name.startswith(".") |
| ] |
| return sorted(p for p in candidates if is_dicom_file(p)) |
|
|
|
|
| def sort_dicom_paths(paths: List[str]) -> List[str]: |
| """Sort slices by physical position when available, otherwise InstanceNumber.""" |
| records = [] |
| for p in paths: |
| try: |
| ds = pydicom.dcmread(p, stop_before_pixels=True, force=True) |
| ipp = getattr(ds, "ImagePositionPatient", None) |
| iop = getattr(ds, "ImageOrientationPatient", None) |
| instance = int(getattr(ds, "InstanceNumber", 0)) |
|
|
| if ipp is not None and iop is not None and len(iop) >= 6 and len(ipp) >= 3: |
| row = np.asarray(iop[:3], dtype=np.float64) |
| col = np.asarray(iop[3:6], dtype=np.float64) |
| normal = np.cross(row, col) |
| position = float(np.dot(np.asarray(ipp[:3], dtype=np.float64), normal)) |
| records.append((0, position, instance, p)) |
| else: |
| records.append((1, instance, 0, p)) |
| except Exception: |
| records.append((2, 0, 0, p)) |
|
|
| records.sort(key=lambda x: (x[0], x[1], x[2], x[3])) |
| return [x[3] for x in records] |
|
|
|
|
| def read_dicom_pixels(path: str, plow: float, phigh: float) -> np.ndarray: |
| ds = pydicom.dcmread(path, force=True) |
| arr = ds.pixel_array.astype(np.float32) |
|
|
| if arr.ndim == 3: |
| arr = arr[..., 0] |
|
|
| slope = _safe_float(getattr(ds, "RescaleSlope", 1.0), 1.0) |
| intercept = _safe_float(getattr(ds, "RescaleIntercept", 0.0), 0.0) |
| arr = arr * slope + intercept |
| arr = np.nan_to_num(arr, nan=0.0, posinf=0.0, neginf=0.0) |
|
|
| photometric = str(getattr(ds, "PhotometricInterpretation", "")).upper() |
| if photometric == "MONOCHROME1": |
| arr = arr.max() - arr |
|
|
| lo, hi = np.percentile(arr, [plow, phigh]) |
| if hi <= lo: |
| lo = float(arr.min()) |
| hi = float(arr.max()) |
|
|
| arr = np.clip(arr, lo, hi) |
| arr = (arr - lo) / (hi - lo + 1e-6) |
| return arr.astype(np.float32, copy=False) |
|
|
|
|
| def resize_slice(img: np.ndarray, size: int) -> np.ndarray: |
| if img.shape == (size, size): |
| return img.astype(np.float32, copy=False) |
|
|
| if cv2 is not None: |
| interpolation = ( |
| cv2.INTER_AREA if (img.shape[0] >= size and img.shape[1] >= size) |
| else cv2.INTER_LINEAR |
| ) |
| return cv2.resize( |
| img.astype(np.float32, copy=False), (size, size), interpolation=interpolation |
| ).astype(np.float32, copy=False) |
|
|
| t = torch.from_numpy(img.astype(np.float32, copy=False))[None, None] |
| out = F.interpolate(t, size=(size, size), mode="bilinear", align_corners=False) |
| return out[0, 0].numpy().astype(np.float32, copy=False) |
|
|
|
|
| def preprocess_slice_float(path: str, cfg: InferCFG) -> np.ndarray: |
| """Read + resize + per-slice z-score + clip to [-5, 5] (float32, no quantization).""" |
| img = read_dicom_pixels(path, cfg.percentile_low, cfg.percentile_high) |
| img = resize_slice(img, cfg.img_size) |
|
|
| mean = float(img.mean()) |
| std = float(img.std()) |
| img = (img - mean) / (std + 1e-6) |
| img = np.clip(img, -5.0, 5.0) |
| return img.astype(np.float32, copy=False) |
|
|
|
|
| def load_series_volume(series_dir: Path, cfg: InferCFG) -> np.ndarray: |
| """Decode every slice of one series folder into shape [n_slices, H, W] float32.""" |
| paths = [str(p) for p in find_dicom_files(series_dir)] |
| if not paths: |
| return np.zeros((0, cfg.img_size, cfg.img_size), dtype=np.float32) |
|
|
| paths = sort_dicom_paths(paths) |
|
|
| slices = [] |
| for p in paths: |
| try: |
| slices.append(preprocess_slice_float(p, cfg)) |
| except Exception as exc: |
| warnings.warn(f"Skipping unreadable slice {p}: {exc}") |
|
|
| if not slices: |
| return np.zeros((0, cfg.img_size, cfg.img_size), dtype=np.float32) |
|
|
| return np.stack(slices, axis=0) |
|
|
|
|
| |
| |
| |
|
|
| def infer_plane_from_orientation(iop) -> str: |
| """Sagittal / Coronal / Axial from ImageOrientationPatient (LPS convention).""" |
| if iop is None or len(iop) < 6: |
| return "Sagittal" |
|
|
| row = np.asarray(iop[:3], dtype=np.float64) |
| col = np.asarray(iop[3:6], dtype=np.float64) |
| normal = np.cross(row, col) |
| axis = int(np.argmax(np.abs(normal))) |
| |
| return {0: "Sagittal", 1: "Coronal", 2: "Axial"}[axis] |
|
|
|
|
| def infer_fluid_fat_from_text(text: str) -> Tuple[int, int]: |
| """Best-effort keyword heuristic on SeriesDescription/ProtocolName/SequenceName.""" |
| t = (text or "").lower() |
|
|
| fat_keywords = [ |
| "fs", "fat sat", "fatsat", "fat-sat", "fatsupp", |
| "stir", "spair", "spir", "tirm", |
| ] |
| fluid_keywords = [ |
| "t2", "pd", "proton density", "stir", "fse t2", "frfse", |
| ] |
|
|
| fat = int(any(k in t for k in fat_keywords)) |
| fluid = int(any(k in t for k in fluid_keywords)) |
| return fluid, fat |
|
|
|
|
| def read_series_metadata(sample_dcm_path: str) -> dict: |
| ds = pydicom.dcmread(sample_dcm_path, stop_before_pixels=True, force=True) |
|
|
| iop = getattr(ds, "ImageOrientationPatient", None) |
| plane = infer_plane_from_orientation(iop) |
|
|
| description = " ".join( |
| str(getattr(ds, tag, "")) |
| for tag in ("SeriesDescription", "ProtocolName", "SequenceName") |
| ) |
| fluid, fat = infer_fluid_fat_from_text(description) |
|
|
| return {"plane": plane, "fluid": fluid, "fat": fat, "description": description.strip()} |
|
|
|
|
| def scan_series_metadata( |
| series_dir: Path, |
| metadata_overrides: Optional[Dict[str, dict]] = None, |
| ) -> Optional[dict]: |
| """Cheap metadata scan (reads only ONE header per series, no pixel decode).""" |
| paths = find_dicom_files(series_dir) |
| n_slices = len(paths) |
| if n_slices == 0: |
| return None |
|
|
| key = series_dir.name |
|
|
| if metadata_overrides and key in metadata_overrides: |
| row = metadata_overrides[key] |
| plane = str(row.get("Anatomical_Plane", "Sagittal")).strip().title() |
| if plane not in PLANES: |
| plane = "Sagittal" |
| fluid = int(pd.to_numeric(row.get("Fluid_Sensitive", 0), errors="coerce") or 0) |
| fat = int(pd.to_numeric(row.get("Fat_Suppression", 0), errors="coerce") or 0) |
| else: |
| try: |
| info = read_series_metadata(str(paths[0])) |
| plane = info["plane"] if info["plane"] in PLANES else "Sagittal" |
| fluid, fat = info["fluid"], info["fat"] |
| except Exception as exc: |
| warnings.warn(f"Could not read metadata for {series_dir}: {exc}") |
| plane, fluid, fat = "Sagittal", 0, 0 |
|
|
| return { |
| "series_dir": series_dir, |
| "id": key, |
| "plane": plane, |
| "fluid": fluid, |
| "fat": fat, |
| "n_slices": n_slices, |
| } |
|
|
|
|
| |
| |
| |
|
|
| def discover_series_dirs(root: Path) -> List[Path]: |
| """Every directory (anywhere under root) that directly contains DICOM files.""" |
| series_dirs = [] |
| for dirpath, _dirnames, _filenames in os.walk(root): |
| d = Path(dirpath) |
| if find_dicom_files(d): |
| series_dirs.append(d) |
| return sorted(series_dirs) |
|
|
|
|
| def group_series_by_study(root: Path, series_dirs: List[Path]) -> Dict[str, List[Path]]: |
| """ |
| Group series folders into studies: |
| - series_dir directly under root -> single study named after root |
| - series_dir nested one level deeper (study/series/...) -> grouped by |
| the first path component below root |
| """ |
| groups: Dict[str, List[Path]] = {} |
|
|
| for s in series_dirs: |
| try: |
| rel_parts = s.relative_to(root).parts |
| except ValueError: |
| rel_parts = s.parts |
|
|
| if len(rel_parts) <= 1: |
| study_id = root.name or "study" |
| else: |
| study_id = rel_parts[0] |
|
|
| groups.setdefault(study_id, []).append(s) |
|
|
| return groups |
|
|
|
|
| |
| |
| |
|
|
| def initial_series_score(s: dict) -> float: |
| score = 0.10 * math.log1p(s["n_slices"]) |
| if s["plane"] in PLANES: |
| score += 0.30 |
| if s["fluid"]: |
| score += 0.25 |
| if s["fat"]: |
| score += 0.10 |
| if s["fluid"] and s["fat"]: |
| score += 0.20 |
| return float(score) |
|
|
|
|
| def select_series_for_study(series_list: List[dict], max_series: int) -> List[dict]: |
| if len(series_list) <= max_series: |
| return series_list |
|
|
| selected: List[dict] = [] |
| used = set() |
|
|
| |
| for plane in PLANES: |
| candidates = [s for s in series_list if s["plane"] == plane] |
| if candidates: |
| best = max(candidates, key=initial_series_score) |
| selected.append(best) |
| used.add(best["id"]) |
|
|
| |
| for fluid, fat in [(1, 1), (1, 0), (0, 1), (0, 0)]: |
| if len(selected) >= max_series: |
| break |
| candidates = [ |
| s for s in series_list |
| if s["fluid"] == fluid and s["fat"] == fat and s["id"] not in used |
| ] |
| if candidates: |
| best = max(candidates, key=initial_series_score) |
| selected.append(best) |
| used.add(best["id"]) |
|
|
| |
| remaining = [s for s in series_list if s["id"] not in used] |
| remaining.sort(key=initial_series_score, reverse=True) |
| for s in remaining: |
| if len(selected) >= max_series: |
| break |
| selected.append(s) |
|
|
| return selected[:max_series] |
|
|
|
|
| |
| |
| |
|
|
| def sample_clip_centers(n_slices: int, n_clips: int) -> List[int]: |
| if n_slices <= 0: |
| return [0] * n_clips |
| if n_slices == 1: |
| return [0] * n_clips |
| if n_clips == 1: |
| return [n_slices // 2] |
| return np.linspace(0, n_slices - 1, n_clips).astype(int).tolist() |
|
|
|
|
| def make_clip(volume: np.ndarray, center: int, img_size: int) -> np.ndarray: |
| n = volume.shape[0] |
| if n == 0: |
| return np.zeros((3, img_size, img_size), dtype=np.float32) |
| center = int(np.clip(center, 0, n - 1)) |
| idxs = [max(0, center - 1), center, min(n - 1, center + 1)] |
| return volume[idxs].astype(np.float32, copy=False) |
|
|
|
|
| def build_study_batch( |
| series_infos: List[dict], cfg: InferCFG |
| ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: |
| """Decode pixels for the SELECTED series only and assemble one study's clips.""" |
| images, planes, fluids, fats = [], [], [], [] |
|
|
| for s in series_infos: |
| volume = load_series_volume(s["series_dir"], cfg) |
| centers = sample_clip_centers(volume.shape[0], cfg.clips_per_series) |
| for c in centers: |
| images.append(make_clip(volume, c, cfg.img_size)) |
| planes.append(PLANE2IDX.get(s["plane"], 0)) |
| fluids.append(float(s["fluid"])) |
| fats.append(float(s["fat"])) |
|
|
| if not images: |
| images = [np.zeros((3, cfg.img_size, cfg.img_size), dtype=np.float32)] |
| planes, fluids, fats = [0], [0.0], [0.0] |
|
|
| return ( |
| np.stack(images, axis=0), |
| np.array(planes, dtype=np.int64), |
| np.array(fluids, dtype=np.float32), |
| np.array(fats, dtype=np.float32), |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class LabelAwareSeriesAttention(nn.Module): |
| def __init__(self, dim: int): |
| super().__init__() |
| self.dim = dim |
|
|
| self.query = nn.Parameter(torch.randn(N_LABELS, dim) * 0.02) |
| self.key = nn.Linear(dim, dim, bias=False) |
| self.value = nn.Linear(dim, dim, bias=False) |
|
|
| self.plane_embed = nn.Embedding(len(PLANES), dim) |
| self.fluid_proj = nn.Linear(1, dim) |
| self.fat_proj = nn.Linear(1, dim) |
|
|
| self.plane_bias = nn.Parameter(torch.zeros(N_LABELS, len(PLANES))) |
| self.fluid_bias = nn.Parameter(torch.zeros(N_LABELS)) |
| self.fat_bias = nn.Parameter(torch.zeros(N_LABELS)) |
|
|
| self.register_buffer("plane_prior", torch.tensor(PLANE_PRIOR, dtype=torch.float32)) |
| self.register_buffer("fluid_prior", torch.tensor(FLUID_PRIOR, dtype=torch.float32)) |
| self.register_buffer("fat_prior", torch.tensor(FAT_SUPPRESSION_PRIOR, dtype=torch.float32)) |
|
|
| self.norm = nn.LayerNorm(dim) |
|
|
| def forward(self, series_feat, plane, fluid, fat): |
| _, d = series_feat.shape |
|
|
| meta = self.plane_embed(plane) + self.fluid_proj(fluid[:, None]) + self.fat_proj(fat[:, None]) |
| z = self.norm(series_feat + meta) |
|
|
| k = self.key(z) |
| v = self.value(z) |
|
|
| scores = torch.matmul(self.query, k.transpose(0, 1)) / math.sqrt(d) |
|
|
| plane_soft = self.plane_prior[:, plane] |
| plane_learn = self.plane_bias[:, plane] |
| fluid_soft = self.fluid_prior[:, None] * fluid[None, :] |
| fluid_learn = self.fluid_bias[:, None] * fluid[None, :] |
| fat_soft = self.fat_prior[:, None] * fat[None, :] |
| fat_learn = self.fat_bias[:, None] * fat[None, :] |
|
|
| bias = plane_soft + plane_learn + fluid_soft + fluid_learn + fat_soft + fat_learn |
| scores = scores + 0.25 * bias |
|
|
| attn = F.softmax(scores, dim=-1) |
| pooled = torch.matmul(attn, v) |
| return pooled, attn |
|
|
|
|
| class LabelTokenTransformer(nn.Module): |
| def __init__(self, dim: int, heads: int, layers: int, dropout: float): |
| super().__init__() |
| self.label_embedding = nn.Parameter(torch.randn(N_LABELS, dim) * 0.02) |
|
|
| encoder_layer = nn.TransformerEncoderLayer( |
| d_model=dim, nhead=heads, dim_feedforward=dim * 4, |
| dropout=dropout, activation="gelu", batch_first=True, norm_first=True, |
| ) |
| self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=layers) |
| self.norm = nn.LayerNorm(dim) |
|
|
| def forward(self, label_features): |
| x = (label_features + self.label_embedding).unsqueeze(0) |
| x = self.transformer(x) |
| return self.norm(x.squeeze(0)) |
|
|
|
|
| class VectorizedLabelHeads(nn.Module): |
| """Equivalent to 12 independent heads, evaluated as batched tensor ops.""" |
|
|
| def __init__(self, dim: int, hidden: int, dropout: float): |
| super().__init__() |
| self.eps = 1e-5 |
| self.ln_weight = nn.Parameter(torch.ones(N_LABELS, dim)) |
| self.ln_bias = nn.Parameter(torch.zeros(N_LABELS, dim)) |
| self.fc1_weight = nn.Parameter(torch.empty(N_LABELS, hidden, dim)) |
| self.fc1_bias = nn.Parameter(torch.zeros(N_LABELS, hidden)) |
| self.fc2_weight = nn.Parameter(torch.empty(N_LABELS, hidden)) |
| self.fc2_bias = nn.Parameter(torch.zeros(N_LABELS)) |
| self.dropout = nn.Dropout(dropout) |
| nn.init.trunc_normal_(self.fc1_weight, std=0.02) |
| nn.init.trunc_normal_(self.fc2_weight, std=0.02) |
|
|
| def forward(self, x): |
| mean = x.mean(dim=-1, keepdim=True) |
| var = x.var(dim=-1, unbiased=False, keepdim=True) |
| z = (x - mean) / torch.sqrt(var + self.eps) |
| z = z * self.ln_weight + self.ln_bias |
| h = torch.einsum("nd,nhd->nh", z, self.fc1_weight) + self.fc1_bias |
| h = F.gelu(h) |
| h = self.dropout(h) |
| logits = torch.einsum("nh,nh->n", h, self.fc2_weight) + self.fc2_bias |
| return logits |
|
|
|
|
| class RSNAKneeModel(nn.Module): |
| def __init__(self, cfg: InferCFG, pretrained: bool = False): |
| super().__init__() |
|
|
| |
| |
| self.backbone = timm.create_model( |
| cfg.backbone_name, pretrained=pretrained, num_classes=0, global_pool="avg", |
| ) |
|
|
| backbone_dim = self.backbone.num_features |
| self.proj = nn.Sequential( |
| nn.Linear(backbone_dim, cfg.embed_dim), |
| nn.LayerNorm(cfg.embed_dim), |
| nn.GELU(), |
| nn.Dropout(cfg.dropout), |
| ) |
| self.series_attention = LabelAwareSeriesAttention(cfg.embed_dim) |
| self.label_transformer = LabelTokenTransformer( |
| cfg.embed_dim, cfg.label_transformer_heads, cfg.label_transformer_layers, cfg.dropout, |
| ) |
| self.label_heads = VectorizedLabelHeads(cfg.embed_dim, cfg.embed_dim // 2, cfg.dropout) |
|
|
| def encode_images(self, images): |
| return self.proj(self.backbone(images)) |
|
|
| def forward(self, batch): |
| images, plane, fluid, fat, offsets = ( |
| batch["images"], batch["plane"], batch["fluid"], batch["fat"], batch["offsets"] |
| ) |
|
|
| series_feat_all = self.encode_images(images) |
|
|
| all_logits, all_attn = [], [] |
| for b in range(len(offsets) - 1): |
| s0, s1 = int(offsets[b]), int(offsets[b + 1]) |
| label_feat, attn = self.series_attention( |
| series_feat_all[s0:s1], plane[s0:s1], fluid[s0:s1], fat[s0:s1], |
| ) |
| label_feat = self.label_transformer(label_feat) |
| logits = self.label_heads(label_feat) |
| all_logits.append(logits) |
| all_attn.append(attn) |
|
|
| return torch.stack(all_logits, dim=0), all_attn |
|
|
|
|
| |
| |
| |
|
|
| def _convert_legacy_label_heads(state: dict) -> dict: |
| """Convert an old 12-ModuleList label-head checkpoint into the vectorized-head format.""" |
| if "label_heads.0.0.weight" not in state or "label_heads.ln_weight" in state: |
| return state |
|
|
| state = dict(state) |
| try: |
| state["label_heads.ln_weight"] = torch.stack( |
| [state.pop(f"label_heads.{i}.0.weight") for i in range(N_LABELS)] |
| ) |
| state["label_heads.ln_bias"] = torch.stack( |
| [state.pop(f"label_heads.{i}.0.bias") for i in range(N_LABELS)] |
| ) |
| state["label_heads.fc1_weight"] = torch.stack( |
| [state.pop(f"label_heads.{i}.1.weight") for i in range(N_LABELS)] |
| ) |
| state["label_heads.fc1_bias"] = torch.stack( |
| [state.pop(f"label_heads.{i}.1.bias") for i in range(N_LABELS)] |
| ) |
| state["label_heads.fc2_weight"] = torch.stack( |
| [state.pop(f"label_heads.{i}.4.weight").squeeze(0) for i in range(N_LABELS)] |
| ) |
| state["label_heads.fc2_bias"] = torch.stack( |
| [state.pop(f"label_heads.{i}.4.bias").squeeze(0) for i in range(N_LABELS)] |
| ) |
| print("[CHECKPOINT] Converted legacy ModuleList label heads -> vectorized label heads.") |
| except Exception as exc: |
| print(f"[WARNING] Legacy label-head conversion failed: {exc}") |
| return state |
|
|
|
|
| def load_model_from_checkpoint(checkpoint_path: str, cfg: InferCFG, device: torch.device) -> nn.Module: |
| checkpoint_path = Path(checkpoint_path) |
| if not checkpoint_path.exists(): |
| raise FileNotFoundError(f"Checkpoint not found: {checkpoint_path}") |
|
|
| model = RSNAKneeModel(cfg, pretrained=False).to(device) |
|
|
| checkpoint = torch.load(checkpoint_path, map_location=device) |
| state = checkpoint.get("model", checkpoint) if isinstance(checkpoint, dict) else checkpoint |
| state = _convert_legacy_label_heads(state) |
|
|
| missing, unexpected = model.load_state_dict(state, strict=False) |
| if missing: |
| print(f"[CHECKPOINT] Missing keys: {len(missing)}") |
| if unexpected: |
| print(f"[CHECKPOINT] Unexpected keys: {len(unexpected)}") |
|
|
| if isinstance(checkpoint, dict) and "ema" in checkpoint: |
| named_params = dict(model.named_parameters()) |
| for name, value in checkpoint["ema"].items(): |
| if name in named_params: |
| named_params[name].data.copy_(value.to(device)) |
| print("[CHECKPOINT] Applied EMA weights.") |
|
|
| model.eval() |
| return model |
|
|
|
|
| |
| |
| |
|
|
| @torch.no_grad() |
| def predict_study( |
| model: nn.Module, series_infos: List[dict], cfg: InferCFG, device: torch.device, |
| ) -> Tuple[Dict[str, float], np.ndarray]: |
| images, planes, fluids, fats = build_study_batch(series_infos, cfg) |
|
|
| x = torch.from_numpy(images) |
| x = (x - IMAGENET_MEAN[0]) / IMAGENET_STD[0] |
| x = x.to(device) |
|
|
| plane_t = torch.from_numpy(planes).to(device) |
| fluid_t = torch.from_numpy(fluids).to(device) |
| fat_t = torch.from_numpy(fats).to(device) |
| offsets = torch.tensor([0, x.shape[0]], dtype=torch.long) |
|
|
| with torch.amp.autocast(device_type=device.type, enabled=(cfg.amp and device.type == "cuda")): |
| logits, attn = model({ |
| "images": x, "plane": plane_t, "fluid": fluid_t, "fat": fat_t, "offsets": offsets, |
| }) |
|
|
| probs = torch.sigmoid(logits)[0].float().cpu().numpy() |
| result = {label: float(np.clip(p, 1e-5, 1.0 - 1e-5)) for label, p in zip(LABELS, probs)} |
| return result, attn[0].float().cpu().numpy() |
|
|
|
|
| def _load_metadata_overrides(metadata_csv: Optional[str]) -> Optional[Dict[str, dict]]: |
| if not metadata_csv: |
| return None |
|
|
| meta_df = pd.read_csv(metadata_csv) |
| if "SeriesFolder" not in meta_df.columns: |
| warnings.warn("--metadata-csv is missing a 'SeriesFolder' column; ignoring overrides.") |
| return None |
|
|
| return {str(row["SeriesFolder"]): row.to_dict() for _, row in meta_df.iterrows()} |
|
|
|
|
| def run_folder_inference( |
| input_folder: str, |
| checkpoint_path: str, |
| cfg: Optional[InferCFG] = None, |
| output_csv: Optional[str] = "predictions.csv", |
| metadata_csv: Optional[str] = None, |
| device_str: Optional[str] = None, |
| save_attention: bool = False, |
| limit_studies: Optional[int] = None, |
| ) -> pd.DataFrame: |
| """Discover every study under `input_folder`, run the model, return a predictions DataFrame.""" |
| cfg = cfg or InferCFG() |
| root = Path(input_folder) |
| if not root.exists(): |
| raise FileNotFoundError(f"Input folder not found: {root}") |
|
|
| series_dirs = discover_series_dirs(root) |
| if not series_dirs: |
| raise RuntimeError(f"No DICOM series found under: {root}") |
|
|
| study_groups = group_series_by_study(root, series_dirs) |
| print(f"Found {len(study_groups)} study(ies), {len(series_dirs)} total series under: {root}") |
|
|
| metadata_overrides = _load_metadata_overrides(metadata_csv) |
|
|
| device = torch.device(device_str or cfg.device) |
| model = load_model_from_checkpoint(checkpoint_path, cfg, device) |
|
|
| study_items = list(study_groups.items()) |
| if limit_studies: |
| study_items = study_items[:limit_studies] |
|
|
| rows = [] |
| attn_dump = {} |
|
|
| for study_id, s_dirs in tqdm(study_items, desc="studies"): |
| infos = [] |
| for sd in s_dirs: |
| meta = scan_series_metadata(sd, metadata_overrides) |
| if meta: |
| infos.append(meta) |
|
|
| if not infos: |
| warnings.warn(f"Study '{study_id}': no readable series, skipping.") |
| continue |
|
|
| selected = select_series_for_study(infos, cfg.max_series) |
| print( |
| f"\nStudy '{study_id}': using {len(selected)}/{len(infos)} series " |
| f"(planes={[s['plane'] for s in selected]}, " |
| f"fluid={[s['fluid'] for s in selected]}, fat={[s['fat'] for s in selected]})" |
| ) |
|
|
| probs, attn = predict_study(model, selected, cfg, device) |
|
|
| row = {"study_id": study_id} |
| row.update(probs) |
| rows.append(row) |
|
|
| if save_attention: |
| attn_dump[study_id] = attn.tolist() |
|
|
| result_df = ( |
| pd.DataFrame(rows)[["study_id"] + LABELS] |
| if rows else pd.DataFrame(columns=["study_id"] + LABELS) |
| ) |
|
|
| if output_csv: |
| result_df.to_csv(output_csv, index=False) |
| print(f"\nSaved predictions: {output_csv}") |
|
|
| if save_attention and attn_dump: |
| attn_path = str(Path(output_csv).with_suffix(".attention.json")) if output_csv else "attention.json" |
| with open(attn_path, "w", encoding="utf-8") as f: |
| json.dump(attn_dump, f) |
| print(f"Saved attention maps: {attn_path}") |
|
|
| return result_df |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| cfg = InferCFG( |
| backbone_name=BACKBONE_NAME, |
| img_size=IMG_SIZE, |
| embed_dim=EMBED_DIM, |
| max_series=MAX_SERIES, |
| clips_per_series=CLIPS_PER_SERIES, |
| ) |
|
|
| df = run_folder_inference( |
| input_folder=INPUT_FOLDER, |
| checkpoint_path=CHECKPOINT_PATH, |
| cfg=cfg, |
| output_csv=OUTPUT_CSV, |
| metadata_csv=METADATA_CSV, |
| device_str=DEVICE, |
| save_attention=SAVE_ATTENTION, |
| limit_studies=LIMIT_STUDIES, |
| ) |
|
|
| print("\n" + "=" * 90) |
| print("PREDICTIONS") |
| print("=" * 90) |
| print(df.to_string(index=False)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |