"""Optic-disc image croppers and preprocessor factory for V2.""" from __future__ import annotations from pathlib import Path from typing import Dict, Optional, Tuple import numpy as np import pandas as pd import torch from PIL import Image, ImageDraw from torchvision import transforms from v3.classes.geometry_features import compute_geometry_features, disc_cup_from_mask_image from v3.classes.unet_segmenter import UNetSegmenter def _geometry_from_mask(mask: np.ndarray, scale: float) -> Dict: mask = np.asarray(mask) > 0 coords = np.argwhere(mask) if coords.size == 0: raise RuntimeError("Empty mask; cannot derive geometry") ys, xs = coords[:, 0], coords[:, 1] centre_x = float(xs.mean()) centre_y = float(ys.mean()) width = float(xs.max() - xs.min()) height = float(ys.max() - ys.min()) diameter = max(width, height) radius = diameter / 2.0 crop_radius = radius * scale return { "centre_x": centre_x, "centre_y": centre_y, "radius": radius, "crop_radius": crop_radius, "crop_size": crop_radius * 2.0, } class UNetImageCropper: def __init__( self, manifest_path: Path, weights_path: Path, normalize: str = "per_image", threshold: float = 0.5, tta: bool = False, scale: float = 2.5, target_size: int = 224, cache_dir: Optional[Path] = None, ) -> None: self.segmenter = UNetSegmenter( manifest_path=manifest_path, normalize=normalize, ) state = torch.load(weights_path, map_location=self.segmenter.device) state_dict = state.get("model", state) self.segmenter.model.load_state_dict(state_dict) self.segmenter.model.to(self.segmenter.device) self.segmenter.model.eval() self.threshold = threshold self.tta = tta self.scale = scale self.target_size = target_size self.cache_dir = Path(cache_dir) if cache_dir is not None else None if self.cache_dir is not None: self.cache_dir.mkdir(parents=True, exist_ok=True) self.to_tensor = transforms.ToTensor() def _cache_path(self, image_path: Path) -> Optional[Path]: if self.cache_dir is None: return None stem = image_path.stem return self.cache_dir / f"{stem}_s{int(self.scale * 100)}.npz" def clear_cache(self) -> None: if self.cache_dir is None or not self.cache_dir.exists(): return removed = sum(1 for f in self.cache_dir.glob("*.npz") if f.unlink() or True) print(f"[UNetImageCropper] Cleared {removed} cached crop files from {self.cache_dir}") def _infer_masks(self, image: Image.Image) -> Optional[Tuple[np.ndarray, np.ndarray]]: resized = self.segmenter.preprocess_image(image) tensor = self.segmenter._normalize_tensor( self.to_tensor(resized).to(self.segmenter.device) ).unsqueeze(0) with torch.no_grad(): logits = self.segmenter.model(tensor) if self.tta: t_h = torch.flip(tensor, dims=[3]) log_h = self.segmenter.model(t_h) log_h = torch.flip(log_h, dims=[3]) t_v = torch.flip(tensor, dims=[2]) log_v = self.segmenter.model(t_v) log_v = torch.flip(log_v, dims=[2]) logits = (logits + log_h + log_v) / 3.0 probs = torch.sigmoid(logits)[0].cpu().numpy() disc_pred = (probs[0] > self.threshold).astype(np.uint8) * 255 cup_pred = (probs[1] > self.threshold).astype(np.uint8) * 255 disc_img = Image.fromarray(disc_pred, mode="L").resize(image.size, Image.NEAREST) disc_mask = np.array(disc_img, dtype=np.uint8) cup_img = Image.fromarray(cup_pred, mode="L").resize(image.size, Image.NEAREST) cup_mask = (np.array(cup_img, dtype=np.uint8) > 0).astype(np.uint8) cup_mask = (cup_mask > 0) & (disc_mask > 0) cup_mask = cup_mask.astype(np.uint8) disc_mask = (disc_mask > 0).astype(np.uint8) return disc_mask, cup_mask def _compute_crop_info(self, image: Image.Image, image_path: Path) -> Optional[dict]: image_path = Path(image_path).resolve() cache_path = self._cache_path(image_path) cached_bounds = None if cache_path is not None and cache_path.exists(): data = np.load(cache_path, allow_pickle=False) try: cached_bounds = { "left": float(data["left"]), "upper": float(data["upper"]), "right": float(data["right"]), "lower": float(data["lower"]), } if "features" in data.files: cached_bounds["features"] = data["features"].astype(np.float32) return cached_bounds except KeyError: cached_bounds = None masks = self._infer_masks(image) if masks is None: return cached_bounds disc_mask, cup_mask = masks try: geom = _geometry_from_mask(disc_mask, self.scale) except Exception: return cached_bounds cx = geom["centre_x"] cy = geom["centre_y"] r = geom["crop_radius"] left = max(0.0, cx - r) upper = max(0.0, cy - r) right = min(float(image.width), cx + r) lower = min(float(image.height), cy + r) features = compute_geometry_features(disc_mask, cup_mask) info = { "left": left, "upper": upper, "right": right, "lower": lower, "features": features, } if cache_path is not None: np.savez( cache_path, left=left, upper=upper, right=right, lower=lower, width=float(image.width), height=float(image.height), scale=self.scale, target_size=self.target_size, features=features, ) return info def __call__(self, image: Image.Image, image_path: Path) -> Image.Image: info = self._compute_crop_info(image, image_path) if info is None: return image left = info["left"] upper = info["upper"] right = info["right"] lower = info["lower"] if right <= left or lower <= upper: return image crop = image.crop((left, upper, right, lower)) return crop.resize((self.target_size, self.target_size), Image.BILINEAR) def geometry_features(self, image: Image.Image, image_path: Path) -> Optional[np.ndarray]: info = self._compute_crop_info(image, image_path) if info is None: return None features = info.get("features") if features is None: return None return np.asarray(features, dtype=np.float32) def precompute_geometry(self, image_paths) -> None: """Pre-compute geometry features for all image_paths into an in-memory cache. Safe to call in the main process; geometry_for_image() can then be called from DataLoader workers without touching CUDA. """ self._geometry_cache: Dict[str, Optional[np.ndarray]] = {} paths = list(image_paths) print(f"[UNetImageCropper] pre-computing geometry for {len(paths)} images...", flush=True) for img_path in paths: key = str(Path(img_path).resolve()) try: img = Image.open(img_path).convert("RGB") self._geometry_cache[key] = self.geometry_features(img, img_path) except Exception: self._geometry_cache[key] = None n_ok = sum(1 for v in self._geometry_cache.values() if v is not None) print(f"[UNetImageCropper] {n_ok}/{len(paths)} geometry vectors computed", flush=True) def geometry_for_image(self, image_path) -> Optional[np.ndarray]: """Return pre-computed geometry vector for image_path (call precompute_geometry first).""" cache = getattr(self, "_geometry_cache", None) if cache is None: raise RuntimeError("Call precompute_geometry() before geometry_for_image()") return cache.get(str(Path(image_path).resolve())) class ManifestImageCropper: def __init__( self, manifest_path: Path, scale: float = 2.5, target_size: int = 224, cache_dir: Optional[Path] = None, ) -> None: self.scale = scale self.target_size = target_size self.cache_dir = Path(cache_dir) if cache_dir is not None else None if self.cache_dir is not None: self.cache_dir.mkdir(parents=True, exist_ok=True) df = pd.read_csv(manifest_path) self.entries: Dict[str, dict] = {} for _, row in df.iterrows(): img_path = Path(row["image_path"]).resolve() self.entries[str(img_path)] = { "annotation_disc": row.get("annotation_disc"), "annotation_cup": row.get("annotation_cup"), "annotation_type_disc": row.get("annotation_type_disc"), "annotation_type_cup": row.get("annotation_type_cup"), } def _cache_path(self, image_path: Path) -> Optional[Path]: if self.cache_dir is None: return None return self.cache_dir / f"{image_path.stem}_s{int(self.scale * 100)}.npz" def clear_cache(self) -> None: if self.cache_dir is None or not self.cache_dir.exists(): return removed = sum(1 for f in self.cache_dir.glob("*.npz") if f.unlink() or True) print(f"[ManifestImageCropper] Cleared {removed} cached crop files from {self.cache_dir}") @staticmethod def _load_contour(path: Path) -> np.ndarray: coords = np.loadtxt(path) if coords.ndim == 1: coords = coords.reshape(-1, 2) return coords @staticmethod def _contour_to_mask(coords: np.ndarray, size: tuple[int, int]) -> np.ndarray: if coords is None or coords.size == 0: return np.zeros((size[1], size[0]), dtype=np.uint8) img = Image.new("L", size, 0) draw = ImageDraw.Draw(img) points = [tuple(map(float, pt)) for pt in coords] draw.polygon(points, outline=1, fill=1) return np.array(img, dtype=np.uint8) def _load_masks(self, entry: dict, image: Image.Image) -> Optional[Tuple[np.ndarray, np.ndarray]]: disc_path = entry.get("annotation_disc") cup_path = entry.get("annotation_cup") disc_type = (entry.get("annotation_type_disc") or "").lower() cup_type = (entry.get("annotation_type_cup") or "").lower() disc_mask: Optional[np.ndarray] = None cup_mask: Optional[np.ndarray] = None if disc_path and not pd.isna(disc_path): disc_path = Path(disc_path) try: if disc_type == "mask": mask_img = Image.open(disc_path) mask_img = mask_img.resize(image.size, Image.NEAREST) disc_mask, cup_from_mask = disc_cup_from_mask_image(mask_img) if cup_from_mask.sum() > 0: cup_mask = cup_from_mask elif disc_type == "contour": coords = self._load_contour(disc_path) disc_mask = self._contour_to_mask(coords, image.size) except Exception: disc_mask = None if cup_mask is None and cup_path and not pd.isna(cup_path): cup_path = Path(cup_path) try: if cup_type == "mask": mask_img = Image.open(cup_path) mask_img = mask_img.resize(image.size, Image.NEAREST) _, cup_mask = disc_cup_from_mask_image(mask_img) elif cup_type == "contour": coords = self._load_contour(cup_path) cup_mask = self._contour_to_mask(coords, image.size) except Exception: cup_mask = None if disc_mask is None: return None disc_mask = (disc_mask > 0).astype(np.uint8) if cup_mask is None: cup_mask = np.zeros_like(disc_mask, dtype=np.uint8) cup_mask = ((cup_mask > 0) & (disc_mask > 0)).astype(np.uint8) return disc_mask, cup_mask def _compute_crop_info(self, image: Image.Image, image_path: Path) -> Optional[dict]: image_path = Path(image_path).resolve() entry = self.entries.get(str(image_path)) if entry is None: return None cache_path = self._cache_path(image_path) cached_bounds = None if cache_path is not None and cache_path.exists(): data = np.load(cache_path, allow_pickle=False) try: cached_bounds = { "left": float(data["left"]), "upper": float(data["upper"]), "right": float(data["right"]), "lower": float(data["lower"]), } if "features" in data.files: cached_bounds["features"] = data["features"].astype(np.float32) return cached_bounds except KeyError: cached_bounds = None masks = self._load_masks(entry, image) if masks is None: return cached_bounds disc_mask, cup_mask = masks try: geom = _geometry_from_mask(disc_mask, self.scale) except Exception: return cached_bounds cx = geom["centre_x"] cy = geom["centre_y"] r = geom["crop_radius"] left = max(0.0, cx - r) upper = max(0.0, cy - r) right = min(float(image.width), cx + r) lower = min(float(image.height), cy + r) features = compute_geometry_features(disc_mask, cup_mask) info = { "left": left, "upper": upper, "right": right, "lower": lower, "features": features, } if cache_path is not None: np.savez( cache_path, left=left, upper=upper, right=right, lower=lower, width=float(image.width), height=float(image.height), scale=self.scale, target_size=self.target_size, features=features, ) return info def __call__(self, image: Image.Image, image_path: Path) -> Image.Image: info = self._compute_crop_info(image, image_path) if info is None: return image left = info["left"] upper = info["upper"] right = info["right"] lower = info["lower"] if right <= left or lower <= upper: return image crop = image.crop((left, upper, right, lower)) return crop.resize((self.target_size, self.target_size), Image.BILINEAR) def geometry_features(self, image: Image.Image, image_path: Path) -> Optional[np.ndarray]: info = self._compute_crop_info(image, image_path) if info is None: return None features = info.get("features") if features is None: return None return np.asarray(features, dtype=np.float32) def precompute_geometry(self, image_paths) -> None: """Pre-compute geometry features for all image_paths into an in-memory cache. Safe to call in the main process; geometry_for_image() can then be called without re-opening images or re-loading annotations. """ self._geometry_cache: Dict[str, Optional[np.ndarray]] = {} paths = list(image_paths) print(f"[ManifestImageCropper] pre-computing geometry for {len(paths)} images...", flush=True) for img_path in paths: key = str(Path(img_path).resolve()) entry = self.entries.get(key) if entry is None: self._geometry_cache[key] = None continue try: img = Image.open(img_path).convert("RGB") self._geometry_cache[key] = self.geometry_features(img, img_path) except Exception: self._geometry_cache[key] = None n_ok = sum(1 for v in self._geometry_cache.values() if v is not None) print(f"[ManifestImageCropper] {n_ok}/{len(paths)} geometry vectors computed", flush=True) def geometry_for_image(self, image_path) -> Optional[np.ndarray]: """Return pre-computed geometry vector for image_path (call precompute_geometry first).""" cache = getattr(self, "_geometry_cache", None) if cache is None: raise RuntimeError("Call precompute_geometry() before geometry_for_image()") return cache.get(str(Path(image_path).resolve())) # --------------------------------------------------------------------------- # Factory # --------------------------------------------------------------------------- def build_image_preprocessor_from_args(args): """Construct the correct image cropper from CLI args, or return None.""" crop_manifest = getattr(args, "img_crop_manifest", None) crop_weights = getattr(args, "img_crop_weights", None) use_gt = bool(getattr(args, "img_crop_gt", False)) if not crop_manifest: return None crop_cache = Path(getattr(args, "img_crop_cache", Path("cache_data/hypertower_crops"))) persist_cache = bool(getattr(args, "persist_img_crop_cache", False)) if use_gt: pre = ManifestImageCropper( manifest_path=Path(crop_manifest), scale=getattr(args, "img_crop_scale", 2.5), target_size=getattr(args, "img_crop_size", 224), cache_dir=crop_cache, ) if not persist_cache: pre.clear_cache() print(f"[V2 modes] GT disc cropper enabled -> cache at {crop_cache}", flush=True) return pre if crop_weights: pre = UNetImageCropper( manifest_path=Path(crop_manifest), weights_path=Path(crop_weights), normalize=getattr(args, "img_crop_normalize", "per_image"), threshold=getattr(args, "img_crop_threshold", 0.5), tta=getattr(args, "img_crop_tta", False), scale=getattr(args, "img_crop_scale", 2.5), target_size=getattr(args, "img_crop_size", 224), cache_dir=crop_cache, ) if not persist_cache: pre.clear_cache() print(f"[V2 modes] UNet disc cropper enabled -> cache at {crop_cache}", flush=True) return pre print( "[V2 modes] img_crop_manifest provided but no --img-crop-gt or --img-crop-weights; cropping disabled.", flush=True, ) return None