from dataclasses import asdict from typing import Dict, Iterator, List, Tuple import numpy as np from PIL import Image from .config import TileConfig from .utils import pil_to_np, np_to_pil def _tissue_mask_rgb(img: Image.Image, min_tissue_frac: float) -> np.ndarray: """ Very lightweight tissue detection surrogate using HSV thresholds. Returns a boolean mask [H,W] where True ≈ tissue. """ import cv2 rgb = pil_to_np(img) # uint8 [H,W,3] bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) # Heuristic: keep pixels with moderate-to-high saturation/value. sat_ok = hsv[..., 1] > 20 val_ok = hsv[..., 2] > 30 mask = sat_ok & val_ok # Morphological open/close to clean noise kernel = np.ones((3, 3), np.uint8) mask = cv2.morphologyEx(mask.astype(np.uint8) * 255, cv2.MORPH_OPEN, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) return mask.astype(bool) def _tile_coords(h: int, w: int, tile: int, stride: int) -> Iterator[Tuple[int, int, int, int]]: """ Yield (y0, y1, x0, x1) windows. Handles right/bottom edges by clipping. """ for y in range(0, max(1, h - tile + 1), stride): for x in range(0, max(1, w - tile + 1), stride): y1 = min(y + tile, h) x1 = min(x + tile, w) # enforce exact size by skipping partial tiles unless at edges with enough size if (y1 - y == tile) and (x1 - x == tile): yield (y, y1, x, x1) class Tiler: """ Slide/large-image tiler: - computes a simple tissue mask - yields tiles and their grid coordinates - filters low-tissue tiles by fraction threshold """ def __init__(self, cfg: TileConfig): self.cfg = cfg def describe(self) -> Dict: return asdict(self.cfg) def iter_tiles( self, img: Image.Image ) -> Iterator[Tuple[int, int, int, int, Image.Image, float]]: """ Yields: (y0, y1, x0, x1, tile_rgb_PIL, tissue_frac) Only tiles with tissue_frac >= min_tissue_frac are yielded. """ tile = self.cfg.tile_size stride = self.cfg.stride mask = _tissue_mask_rgb(img, self.cfg.min_tissue_frac) arr = pil_to_np(img) H, W = arr.shape[:2] for y0, y1, x0, x1 in _tile_coords(H, W, tile, stride): m = mask[y0:y1, x0:x1] tissue_frac = float(m.mean()) if m.size else 0.0 if tissue_frac < self.cfg.min_tissue_frac: continue crop = arr[y0:y1, x0:x1, :] yield (y0, y1, x0, x1, np_to_pil(crop), tissue_frac) def tile_image( self, img: Image.Image ) -> List[Dict[str, object]]: """ Convenience: materialize tiles into a list of dicts for caching. Each dict: {"y0": int, "y1": int, "x0": int, "x1": int, "tissue_frac": float, "image": PIL.Image} """ out: List[Dict[str, object]] = [] for y0, y1, x0, x1, tile_img, frac in self.iter_tiles(img): out.append( {"y0": y0, "y1": y1, "x0": x0, "x1": x1, "tissue_frac": frac, "image": tile_img} ) return out def grid_shape(self, img: Image.Image) -> Tuple[int, int]: """ Return approximate (rows, cols) of the tiling grid for visualization reference. """ H, W = pil_to_np(img).shape[:2] rows = max(1, (H - self.cfg.tile_size) // self.cfg.stride + 1) cols = max(1, (W - self.cfg.tile_size) // self.cfg.stride + 1) return rows, cols