From ba17bc0211a5ab99de32e058315e56f09509a9ae Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Sat, 13 Sep 2025 19:14:01 +0200 Subject: [PATCH 01/12] mvp --- src/squidpy/__init__.py | 2 +- src/squidpy/exp/__init__.py | 12 + src/squidpy/exp/im/__init__.py | 3 + src/squidpy/exp/im/_qc.py | 981 +++++++++++++++++++++++++++++++++ src/squidpy/im/_feature.py | 2 +- 5 files changed, 998 insertions(+), 2 deletions(-) create mode 100644 src/squidpy/exp/__init__.py create mode 100644 src/squidpy/exp/im/__init__.py create mode 100644 src/squidpy/exp/im/_qc.py diff --git a/src/squidpy/__init__.py b/src/squidpy/__init__.py index 5fb2b848a..6298d9227 100644 --- a/src/squidpy/__init__.py +++ b/src/squidpy/__init__.py @@ -3,7 +3,7 @@ from importlib import metadata from importlib.metadata import PackageMetadata -from squidpy import datasets, gr, im, pl, read, tl +from squidpy import datasets, gr, im, pl, read, tl, exp try: md: PackageMetadata = metadata.metadata(__name__) diff --git a/src/squidpy/exp/__init__.py b/src/squidpy/exp/__init__.py new file mode 100644 index 000000000..571e3ccb5 --- /dev/null +++ b/src/squidpy/exp/__init__.py @@ -0,0 +1,12 @@ +"""Experimental module for Squidpy. + +This module contains experimental features that are still under development. +These features may change or be removed in future releases. +""" + +from __future__ import annotations + +from . import im +from .im._qc import qc_sharpness + +__all__ = ["qc_sharpness"] \ No newline at end of file diff --git a/src/squidpy/exp/im/__init__.py b/src/squidpy/exp/im/__init__.py new file mode 100644 index 000000000..b896344f9 --- /dev/null +++ b/src/squidpy/exp/im/__init__.py @@ -0,0 +1,3 @@ +from ._qc import qc_sharpness, detect_tissue + +__all__ = ["qc_sharpness", "detect_tissue"] \ No newline at end of file diff --git a/src/squidpy/exp/im/_qc.py b/src/squidpy/exp/im/_qc.py new file mode 100644 index 000000000..54657e4e6 --- /dev/null +++ b/src/squidpy/exp/im/_qc.py @@ -0,0 +1,981 @@ +import numpy as np +import dask.array as da +import itertools +import xarray as xr +from dask.diagnostics import ProgressBar +from scipy import ndimage as ndi +from scipy.stats import entropy +from scipy.fft import fft2, fftfreq +from typing import Literal, Optional, Tuple, Union +from sklearn.preprocessing import StandardScaler +from spatialdata._logging import logger as logg +import pandas as pd +from anndata import AnnData +from spatialdata.models import TableModel, ShapesModel +from shapely.geometry import Polygon +import geopandas as gpd +from enum import Enum + +def _ensure_yxc(img_da: xr.DataArray) -> xr.DataArray: + """ + Ensure dims are (y, x, c). SpatialData often uses (c, y, x). + Adds a length-1 "c" if missing. + """ + dims = list(img_da.dims) + if "y" not in dims or "x" not in dims: + raise ValueError(f"Expected dims to include \"y\" and \"x\". Found dims={dims}") + if "c" in dims: + return img_da.transpose("y", "x", "c") + return img_da.expand_dims({"c": [0]}).transpose("y", "x", "c") + + +def _to_gray_dask_yx(img_yxc: xr.DataArray, weights=(0.2126, 0.7152, 0.0722)) -> da.Array: + """ + Dask-native grayscale conversion. Expects (y, x, c). + For RGBA, ignores alpha; for single-channel, returns it as-is. + """ + arr = img_yxc.data + if arr.ndim != 3: + raise ValueError("Expected a 3D array (y, x, c).") + c = arr.shape[2] + if c == 1: + return arr[..., 0].astype(np.float32, copy=False) + rgb = arr[..., :3].astype(np.float32, copy=False) + w = da.from_array(np.asarray(weights, dtype=np.float32), chunks=(3,)) + gray = da.tensordot(rgb, w, axes=([2], [0])) # -> (y, x) + return gray.astype(np.float32, copy=False) + + +def _sobel_energy_np(block_2d: np.ndarray) -> np.ndarray: + """ + NumPy kernel for Sobel energy (Tenengrad): gx^2 + gy^2. + """ + gx = ndi.sobel(block_2d, axis=0, mode="reflect") + gy = ndi.sobel(block_2d, axis=1, mode="reflect") + out = gx.astype(np.float32) ** 2 + gy.astype(np.float32) ** 2 + return out + + +def _laplace_square_np(block_2d: np.ndarray) -> np.ndarray: + """ + NumPy kernel for squared Laplacian. + """ + lap = ndi.laplace(block_2d, mode="reflect").astype(np.float32) + return lap * lap + + +def _variance_np(block_2d: np.ndarray) -> np.ndarray: + """ + NumPy kernel for variance metric. + Returns the variance of the block as a constant array. + """ + var_val = np.var(block_2d, dtype=np.float32) + return np.full_like(block_2d, var_val, dtype=np.float32) + + +def _modified_laplacian_np(block_2d: np.ndarray) -> np.ndarray: + """ + NumPy kernel for Sum of Modified Laplacian. + Uses absolute values of second-order derivatives. + """ + # Calculate second-order derivatives + dxx = ndi.convolve1d(block_2d, [1, -2, 1], axis=1, mode="reflect") + dyy = ndi.convolve1d(block_2d, [1, -2, 1], axis=0, mode="reflect") + + # Sum of absolute values of second-order derivatives + modified_lap = np.abs(dxx) + np.abs(dyy) + return modified_lap.astype(np.float32) + + +def _entropy_histogram_np(block_2d: np.ndarray) -> np.ndarray: + """ + NumPy kernel for entropy histogram metric. + Returns the entropy of the intensity histogram as a constant array. + """ + # Calculate histogram (256 bins for 8-bit images) + hist, _ = np.histogram(block_2d, bins=256, range=(0, 256)) + hist = hist.astype(np.float32) + + # Normalize histogram to probabilities + hist = hist / np.sum(hist) + + # Calculate entropy (avoid log(0) by adding small epsilon) + hist = hist + 1e-10 + entropy_val = entropy(hist) + + return np.full_like(block_2d, entropy_val, dtype=np.float32) + + +def _fft_high_freq_energy_np(block_2d: np.ndarray) -> np.ndarray: + """ + NumPy kernel for FFT high-frequency energy ratio. + Calculates ratio of high-frequency energy to total energy. + """ + # Compute 2D FFT + fft_coeffs = fft2(block_2d.astype(np.float32)) + fft_magnitude = np.abs(fft_coeffs) + + # Create frequency grids + h, w = block_2d.shape + freq_y = fftfreq(h) + freq_x = fftfreq(w) + freq_grid_y, freq_grid_x = np.meshgrid(freq_y, freq_x, indexing='ij') + freq_radius = np.sqrt(freq_grid_y**2 + freq_grid_x**2) + + # Define high-frequency mask (exclude DC and very low frequencies) + # Use 10% of Nyquist frequency as threshold + high_freq_mask = freq_radius > 0.1 + + # Calculate energies + total_energy = np.sum(fft_magnitude**2) + high_freq_energy = np.sum((fft_magnitude**2)[high_freq_mask]) + + # Calculate ratio (avoid division by zero) + if total_energy > 0: + ratio = high_freq_energy / total_energy + else: + ratio = 0.0 + + return np.full_like(block_2d, ratio, dtype=np.float32) + + +def _haar_wavelet_energy_np(block_2d: np.ndarray) -> np.ndarray: + """ + NumPy kernel for Haar wavelet detail-band energy. + Calculates energy in LH/HL/HH bands normalized by total energy. + Implements manual 2D Haar wavelet transform. + """ + try: + # Ensure even dimensions for Haar wavelet + h, w = block_2d.shape + data = block_2d.astype(np.float32) + + # Pad to even dimensions if needed + if h % 2 == 1: + data = np.vstack([data, data[-1:, :]]) + if w % 2 == 1: + data = np.hstack([data, data[:, -1:]]) + + # Manual 2D Haar wavelet transform + h_new, w_new = data.shape + + # Step 1: Horizontal decomposition (rows) + # Low-pass: (even + odd) / 2 + cA_h = (data[::2, :] + data[1::2, :]) / 2 # Approximation rows + # High-pass: (even - odd) / 2 + cH_h = (data[::2, :] - data[1::2, :]) / 2 # Detail rows + + # Step 2: Vertical decomposition (columns) on both subbands + # On approximation subband + cA = (cA_h[:, ::2] + cA_h[:, 1::2]) / 2 # LL (approximation) + cH = (cA_h[:, ::2] - cA_h[:, 1::2]) / 2 # LH (horizontal detail) + + # On detail subband + cV = (cH_h[:, ::2] + cH_h[:, 1::2]) / 2 # HL (vertical detail) + cD = (cH_h[:, ::2] - cH_h[:, 1::2]) / 2 # HH (diagonal detail) + + # Calculate energies + total_energy = np.sum(cA**2) + np.sum(cH**2) + np.sum(cV**2) + np.sum(cD**2) + detail_energy = np.sum(cH**2) + np.sum(cV**2) + np.sum(cD**2) # LH, HL, HH bands + + # Calculate normalized detail energy ratio + if total_energy > 0: + ratio = detail_energy / total_energy + else: + ratio = 0.0 + + except Exception as e: + # Fallback if wavelet transform fails + ratio = 0.0 + + return np.full_like(block_2d, ratio, dtype=np.float32) + + +def _detect_tissue_rgb(img_da, tile_indices: np.ndarray, tiles_y: int, tiles_x: int, ty: int, tx: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Internal function to detect tissue vs background using RGB values with Dask optimization. + + Parameters: + ----------- + img_da : dask.array + Image data in (y, x, c) format + tile_indices : np.ndarray + Array of shape (n_tiles, 2) with [y_idx, x_idx] for each tile + tiles_y, tiles_x : int + Grid dimensions + ty, tx : int + Tile size dimensions + + Returns: + -------- + tissue_mask : np.ndarray + Boolean array where True indicates tissue tiles + background_mask : np.ndarray + Boolean array where True indicates background tiles + tissue_similarities : np.ndarray + Similarity scores to tissue reference + background_similarities : np.ndarray + Similarity scores to background reference + """ + from spatialdata._logging import logger as logg + from dask.diagnostics import ProgressBar + + H, W, C = img_da.shape + n_tiles = len(tile_indices) + + if n_tiles > 0: + # Collect all tile mean operations as Dask arrays + tile_means = [] + for y_idx, x_idx in tile_indices: + # Calculate tile boundaries + y0 = y_idx * ty + y1 = min((y_idx + 1) * ty, H) + x0 = x_idx * tx + x1 = min((x_idx + 1) * tx, W) + + # Extract tile as Dask array and compute mean + tile = img_da[y0:y1, x0:x1] + tile_mean = tile.mean(axis=(0, 1)) + tile_means.append(tile_mean) + + stacked_means = da.stack(tile_means, axis=0) # Shape: (n_tiles, C) + + with ProgressBar(): + rgb_data = stacked_means.compute() # Single progress bar for all tiles + + else: + logg.warning("No tiles found for processing") + rgb_data = np.zeros((0, C), dtype=np.float32) + + # Identify reference tiles + # Corner tiles (background reference) + corner_positions = [ + (0, 0), # Top-left + (0, tiles_x - 1), # Top-right + (tiles_y - 1, 0), # Bottom-left + (tiles_y - 1, tiles_x - 1) # Bottom-right + ] + + # Center tiles (tissue reference) + center_y_start = int(tiles_y * 0.3) + center_y_end = int(tiles_y * 0.7) + center_x_start = int(tiles_x * 0.3) + center_x_end = int(tiles_x * 0.7) + + # Get corner tile indices + corner_indices = [] + for y_pos, x_pos in corner_positions: + corner_idx = np.where((tile_indices[:, 0] == y_pos) & (tile_indices[:, 1] == x_pos))[0] + if len(corner_idx) > 0: + corner_indices.append(corner_idx[0]) + + # Get center tile indices + center_indices = [] + for y_pos, x_pos in itertools.product(range(center_y_start, center_y_end), range(center_x_start, center_x_end)): + center_idx = np.where((tile_indices[:, 0] == y_pos) & (tile_indices[:, 1] == x_pos))[0] + if len(center_idx) > 0: + center_indices.append(center_idx[0]) + + if len(corner_indices) < 2 or len(center_indices) < 2: + logg.warning("Not enough reference tiles found, classifying all as tissue") + tissue_mask = np.ones(n_tiles, dtype=bool) + background_mask = ~tissue_mask + tissue_similarities = np.ones(n_tiles) + background_similarities = np.zeros(n_tiles) + else: + # Get reference RGB profiles + corner_rgb = rgb_data[corner_indices] # Shape: (n_corners, n_channels) + center_rgb = rgb_data[center_indices] # Shape: (n_center, n_channels) + + # Calculate mean reference profiles + background_reference = np.mean(corner_rgb, axis=0) + tissue_reference = np.mean(center_rgb, axis=0) + + # Calculate similarity to both references for all tiles using cosine similarity + with ProgressBar(): + rgb_norm = rgb_data / (np.linalg.norm(rgb_data, axis=1, keepdims=True) + 1e-8) + bg_norm = background_reference / (np.linalg.norm(background_reference) + 1e-8) + tissue_norm = tissue_reference / (np.linalg.norm(tissue_reference) + 1e-8) + + # Calculate similarities + background_similarities = np.dot(rgb_norm, bg_norm) + tissue_similarities = np.dot(rgb_norm, tissue_norm) + + # Higher similarity to corners = background, higher similarity to center = tissue + background_mask = background_similarities > tissue_similarities + tissue_mask = ~background_mask + + n_background = np.sum(background_mask) + n_tissue = np.sum(tissue_mask) + + return tissue_mask, background_mask, tissue_similarities, background_similarities + + +def detect_tissue(sdata, image_key: str, scale: str = "scale0", tile_size: Union[Literal["auto"], Tuple[int, int]] = "auto") -> AnnData: + """ + Detect tissue vs background tiles using RGB values from corner (background) vs center (tissue) references. + + Parameters: + ----------- + sdata : SpatialData + Your SpatialData object. + image_key : str + Key in sdata.images. + scale : str + Multiscale level, e.g. "scale0". + tile_size : "auto" or (int, int) + Tile size dimensions. When "auto", creates roughly 100x100 grid overlay + with minimum 100x100 pixel tiles. + + Returns: + -------- + AnnData + AnnData object with tissue classification results in obs columns: + - "is_tissue": Boolean indicating tissue tiles + - "is_background": Boolean indicating background tiles + - "tissue_similarity": Similarity to tissue reference (0-1) + - "background_similarity": Similarity to background reference (0-1) + """ + from spatialdata._logging import logger as logg + + # Get image data + img_node = sdata.images[image_key][scale] + img_da = img_node.image + + # Ensure image is in (y, x, c) format + img_da = _ensure_yxc(img_da) + H, W, C = img_da.shape + + logg.info(f"Preparing sharpness metrics calculation.") + logg.info(f"- Image size (x, y) is ({W}, {H}), channels: {C}.") + + # Calculate tile size + if tile_size == "auto": + ty, tx = _calculate_auto_tile_size(H, W) + else: + ty, tx = tile_size + if len(tile_size) == 3: + raise ValueError("tile_size must be 2D (y, x), not 3D") + + logg.info(f"Using tiles with size (x, y): ({tx}, {ty})") + + # Calculate number of tiles + tiles_y = (H + ty - 1) // ty + tiles_x = (W + tx - 1) // tx + n_tiles = tiles_y * tiles_x + + # Create tile indices and boundaries using helper function + tile_indices, obs_names, pixel_bounds = _create_tile_indices_and_bounds( + tiles_y, tiles_x, ty, tx, H, W + ) + + # Use shared tissue detection function + tissue_mask, background_mask, tissue_similarities, background_similarities = _detect_tissue_rgb( + img_da, tile_indices, tiles_y, tiles_x, ty, tx + ) + + # Create AnnData object + adata = AnnData(X=np.zeros((n_tiles, 1))) # Dummy X matrix + adata.var_names = ["dummy"] + adata.obs_names = obs_names + + # Add tissue classification results + adata.obs["is_tissue"] = pd.Categorical(tissue_mask.astype(str), categories=["False", "True"]) + adata.obs["is_background"] = pd.Categorical(background_mask.astype(str), categories=["False", "True"]) + adata.obs["tissue_similarity"] = tissue_similarities + adata.obs["background_similarity"] = background_similarities + + # Add tile grid indices + adata.obs["tile_y"] = tile_indices[:, 0] + adata.obs["tile_x"] = tile_indices[:, 1] + + # Add tile boundaries in pixels (already calculated by helper function) + adata.obs["pixel_y0"] = pixel_bounds[:, 0] + adata.obs["pixel_x0"] = pixel_bounds[:, 1] + adata.obs["pixel_y1"] = pixel_bounds[:, 2] + adata.obs["pixel_x1"] = pixel_bounds[:, 3] + + # Add metadata + adata.uns["tissue_detection"] = { + "image_key": image_key, + "scale": scale, + "tile_size_y": ty, + "tile_size_x": tx, + "image_height": H, + "image_width": W, + "n_tiles_y": tiles_y, + "n_tiles_x": tiles_x, + "n_tissue_tiles": int(np.sum(tissue_mask)), + "n_background_tiles": int(np.sum(background_mask)), + "method": "rgb_similarity" + } + + return adata + + +def _detect_sharpness_outliers(X: np.ndarray, method: str = "iqr") -> np.ndarray: + """ + Detect tiles with low sharpness (blurry/out-of-focus) using parameter-free methods. + + Parameters: + ----------- + X : np.ndarray + Sharpness metrics array of shape (n_tiles, n_metrics) + method : str + Method to use: "iqr" or "zscore" (both parameter-free) + + Returns: + -------- + outlier_labels : np.ndarray + Array of -1 (low sharpness outlier) or 1 (normal sharpness) labels + """ + # Standardize the features + scaler = StandardScaler() + X_scaled = scaler.fit_transform(X) + + if method == "iqr": + return _detect_outliers_iqr(X_scaled) + elif method == "zscore": + return _detect_outliers_zscore(X_scaled) + else: + raise ValueError(f"Unknown method: {method}. Use 'iqr' or 'zscore'.") + + +def _detect_outliers_iqr(X_scaled: np.ndarray) -> np.ndarray: + """Detect outliers using Interquartile Range (IQR) method - only low sharpness.""" + # Calculate IQR for each metric + Q1 = np.percentile(X_scaled, 25, axis=0) + Q3 = np.percentile(X_scaled, 75, axis=0) + IQR = Q3 - Q1 + + # Define outlier bounds (1.5 * IQR rule) - only lower bound for low sharpness + lower_bound = Q1 - 1.5 * IQR + + # Flag outliers only if sharpness is LOW (below lower bound) + # High sharpness is good, so we don't flag upper outliers + outlier_mask = np.any(X_scaled < lower_bound, axis=1) + + # Convert to -1/1 format + outlier_labels = np.where(outlier_mask, -1, 1) + + return outlier_labels + + +def _detect_outliers_zscore(X_scaled: np.ndarray, threshold: float = 3.0) -> np.ndarray: + """Detect outliers using Z-score method - only low sharpness.""" + # Calculate Z-scores (X_scaled is already standardized) + # Only check for LOW sharpness (negative z-scores) + z_scores = X_scaled # X_scaled is already standardized + + # Flag tiles that have LOW sharpness (negative z-scores below threshold) + # High sharpness (positive z-scores) is good, so we don't flag those + outlier_mask = np.any(z_scores < -threshold, axis=1) + + # Convert to -1/1 format + outlier_labels = np.where(outlier_mask, -1, 1) + + return outlier_labels + + + + +def _calculate_auto_tile_size(height: int, width: int) -> Tuple[int, int]: + """ + Calculate tile size for auto mode: roughly 100x100 grid overlay with 100px minimum. + Creates square tiles that evenly divide the image dimensions. + + Parameters + ---------- + height : int + Image height in pixels + width : int + Image width in pixels + + Returns + ------- + Tuple[int, int] + Tile size as (y, x) in pixels (always square) + """ + # Calculate how many tiles we want (roughly 100 in the larger dimension) + target_tiles = 100 + + # Calculate tile size for each dimension + tile_size_y = height // target_tiles + tile_size_x = width // target_tiles + + # Use the smaller tile size to ensure both dimensions are covered + tile_size = min(tile_size_y, tile_size_x) + + # Ensure minimum 100x100 pixel tiles + if tile_size < 100: + return 100, 100 + + return tile_size, tile_size + + +def _make_tiles( + arr_yx: da.Array, + tile_size: Union[Literal["auto"], Tuple[int, int]], +) -> Tuple[int, int]: + """ + Decide tile size based on tile_size parameter. + + Parameters + ---------- + arr_yx : da.Array + Dask array with (y, x) dimensions + tile_size : "auto" or (int, int) + Tile size dimensions. When "auto", creates roughly 100x100 grid overlay. + + Returns + ------- + Tuple[int, int] + Tile size as (y, x) in pixels + """ + + if tile_size == "auto": + return _calculate_auto_tile_size(arr_yx.shape[0], arr_yx.shape[1]) + + if isinstance(tile_size, tuple): + if len(tile_size) == 2: + return int(tile_size[0]), int(tile_size[1]) + else: + raise ValueError(f"tile_size tuple must have exactly 2 dimensions (y, x), got {len(tile_size)}") + + raise ValueError(f"tile_size must be 'auto' or a 2-tuple, got {type(tile_size)}") + + +def _create_tile_indices_and_bounds( + tiles_y: int, + tiles_x: int, + tile_size_y: int, + tile_size_x: int, + image_height: int, + image_width: int +) -> Tuple[np.ndarray, list, np.ndarray]: + """ + Create tile indices, observation names, and pixel boundaries. + + Parameters + ---------- + tiles_y, tiles_x : int + Grid dimensions + tile_size_y, tile_size_x : int + Tile size dimensions + image_height, image_width : int + Image dimensions + + Returns + ------- + Tuple[np.ndarray, list, np.ndarray] + Tuple containing: + - tile_indices: Array of shape (n_tiles, 2) with [y_idx, x_idx] + - obs_names: List of observation names + - pixel_bounds: Array of shape (n_tiles, 4) with [y0, x0, y1, x1] + """ + tile_indices = [] + obs_names = [] + pixel_bounds = [] + + for y_idx in range(tiles_y): + for x_idx in range(tiles_x): + tile_indices.append([y_idx, x_idx]) + obs_names.append(f"tile_x{x_idx}_y{y_idx}") + + # Calculate tile boundaries ensuring complete coverage + y0 = y_idx * tile_size_y + x0 = x_idx * tile_size_x + y1 = (y_idx + 1) * tile_size_y if y_idx < tiles_y - 1 else image_height + x1 = (x_idx + 1) * tile_size_x if x_idx < tiles_x - 1 else image_width + pixel_bounds.append([y0, x0, y1, x1]) + + return np.array(tile_indices), obs_names, np.array(pixel_bounds) + + +def _create_tile_polygons( + scores: np.ndarray, + tile_size_y: int, + tile_size_x: int, + image_height: int, + image_width: int +) -> Tuple[np.ndarray, list]: + """ + Create rectangular polygons for each tile in the grid. + + Parameters + ---------- + scores : np.ndarray + 2D array of tile scores with shape (tiles_y, tiles_x) + tile_size_y : int + Height of each tile in pixels + tile_size_x : int + Width of each tile in pixels + image_height : int + Total image height in pixels + image_width : int + Total image width in pixels + + Returns + ------- + Tuple[np.ndarray, list] + Tuple containing: + - 2D array of tile centroids (y, x coordinates) + - List of Polygon objects for each tile + """ + tiles_y, tiles_x = scores.shape + centroids = [] + polygons = [] + + for y_idx in range(tiles_y): + for x_idx in range(tiles_x): + # Calculate tile boundaries in pixel coordinates + # Ensure complete coverage by extending last tiles to image boundaries + y0 = y_idx * tile_size_y + x0 = x_idx * tile_size_x + y1 = (y_idx + 1) * tile_size_y if y_idx < tiles_y - 1 else image_height + x1 = (x_idx + 1) * tile_size_x if x_idx < tiles_x - 1 else image_width + + # Calculate centroid + centroid_y = (y0 + y1) / 2 + centroid_x = (x0 + x1) / 2 + centroids.append([centroid_y, centroid_x]) + + # Create rectangular polygon for this tile + # Note: Polygon expects (x, y) coordinates, not (y, x) + polygon = Polygon([ + (x0, y0), # bottom-left + (x1, y0), # bottom-right + (x1, y1), # top-right + (x0, y1), # top-left + (x0, y0) # close polygon + ]) + polygons.append(polygon) + + return np.array(centroids), polygons + +class SHARPNESS_METRICS(Enum): + TENENGRAD = "tenengrad" + LAPLACIAN = "laplacian" + VARIANCE = "variance" + MODIFIED_LAPLACIAN = "modified_laplacian" + ENTROPY_HISTOGRAM = "entropy_histogram" + FFT_HIGH_FREQ_ENERGY = "fft_high_freq_energy" + HAAR_WAVELET_ENERGY = "haar_wavelet_energy" + + +def qc_sharpness( + sdata, + image_key: str, + scale: str = "scale0", + metrics: Union[SHARPNESS_METRICS, Literal["all"]] = "all", + tile_size: Union[Literal["auto"], Tuple[int, int]] = "auto", + progress: bool = True, + detect_outliers: bool = True, + detect_tissue: bool = True, + outlier_method: str = "zscore", +) -> None: + """ + Compute tilewise sharpness scores for multiple metrics and print the values. + + Parameters + ---------- + sdata : SpatialData + Your SpatialData object. + image_key : str + Key in sdata.images. + scale : str + Multiscale level, e.g. "scale0". + metrics : SHARPNESS_METRICS or "all" + Sharpness metrics to compute. If "all", computes all available metrics. + tile_size : "auto" or (int, int) + Tile size dimensions. When "auto", creates roughly 100x100 grid overlay + with minimum 100x100 pixel tiles. + progress : bool + Show a Dask progress bar for the compute step. + detect_outliers : bool + If True, identify tiles with abnormal sharpness scores. If `detect_tissue=True`, + outlier detection will only run on tissue tiles. Adds `sharpness_outlier` column + to the AnnData table. + detect_tissue : bool + Only evaluated if `detect_outliers=True`. If True, classify tiles as background + vs tissue using RGB values from corner (background) vs center (tissue) + references. Adds `is_tissue`, `is_background`, `tissue_similarity`, and + `background_similarity` columns to the AnnData table. + outlier_method : str + Method for detecting low sharpness tiles: "iqr" or "zscore" (both parameter-free). + Default "zscore" uses Z-score method. Only flags tiles with LOW sharpness. + + Returns + ------- + None + Prints results to stdout and adds TableModel and ShapesModel to sdata. + Table key: "qc_img_{image_key}_sharpness" + Shapes key: "qc_img_{image_key}_sharpness_grid" + When `detect_outliers=True`, adds "sharpness_outlier" column to AnnData.obs. + When `detect_tissue=True` and `detect_outliers=True`, also adds "is_tissue", + "is_background", "tissue_similarity", and "background_similarity" columns. + "sharpness_outlier" flags tiles with low sharpness (blurry/out-of-focus). + """ + # 1) Get Dask-backed image (no materialization) + img_node = sdata.images[image_key][scale] + img_da = img_node.image + + # 2) Ensure dims and grayscale + img_yxc = _ensure_yxc(img_da) + gray = _to_gray_dask_yx(img_yxc) # (y, x), float32 dask array + H, W = gray.shape + + # 3) Determine which metrics to compute + + # 4) Calculate tile size + ty, tx = _make_tiles(gray, tile_size) + logg.info(f"Preparing sharpness metrics calculation.") + logg.info(f"- Image size (x, y) is ({W}, {H}), using tiles with size (x, y): ({tx}, {ty}).") + + # Calculate tile grid dimensions + tiles_y = (H + ty - 1) // ty + tiles_x = (W + tx - 1) // tx + n_tiles = tiles_y * tiles_x + logg.info(f"- Resulting tile grid has shape (x, y): ({tiles_x}, {tiles_y}).") + + # 5) Compute sharpness scores for all metrics + all_scores = {} + + print("") + logg.info(f"Calculating sharpness metrics.") + metrics_to_compute = list(SHARPNESS_METRICS) if metrics == "all" else [metrics] + for metric in metrics_to_compute: + metric_name = metric.value if isinstance(metric, SHARPNESS_METRICS) else metric + logg.info(f"- Computing sharpness metric '{metric_name}'.") + + # Per-pixel sharpness via map_overlap (no overlap for adjacent tiles) + if metric_name == "tenengrad": + sharp_field = da.map_overlap( + _sobel_energy_np, gray, depth=0, boundary="reflect", dtype=np.float32 + ) + elif metric_name == "laplacian": + sharp_field = da.map_overlap( + _laplace_square_np, gray, depth=0, boundary="reflect", dtype=np.float32 + ) + elif metric_name == "variance": + sharp_field = da.map_overlap( + _variance_np, gray, depth=0, boundary="reflect", dtype=np.float32 + ) + elif metric_name == "modified_laplacian": + sharp_field = da.map_overlap( + _modified_laplacian_np, gray, depth=0, boundary="reflect", dtype=np.float32 + ) + elif metric_name == "entropy_histogram": + sharp_field = da.map_overlap( + _entropy_histogram_np, gray, depth=0, boundary="reflect", dtype=np.float32 + ) + elif metric_name == "fft_high_freq_energy": + sharp_field = da.map_overlap( + _fft_high_freq_energy_np, gray, depth=0, boundary="reflect", dtype=np.float32 + ) + elif metric_name == "haar_wavelet_energy": + sharp_field = da.map_overlap( + _haar_wavelet_energy_np, gray, depth=0, boundary="reflect", dtype=np.float32 + ) + else: + raise ValueError(f"- Unknown metric {metric_name}.") + + # Use the tile dimensions calculated earlier (lines 716-717) + # tiles_y and tiles_x are already calculated and correct + + # Pad the array to make it divisible by tile size + pad_y = (tiles_y * ty) - sharp_field.shape[0] + pad_x = (tiles_x * tx) - sharp_field.shape[1] + + if pad_y > 0 or pad_x > 0: + # Pad with edge values + padded = da.pad(sharp_field, ((0, pad_y), (0, pad_x)), mode='edge') + else: + padded = sharp_field + + # Now coarsen with trim_excess=False since dimensions are divisible + tile_scores = da.coarsen(np.mean, padded, {0: ty, 1: tx}, trim_excess=False) + + # Compute scores with progress bar if requested + if progress: + with ProgressBar(): + scores = tile_scores.compute() # 2D numpy array + else: + scores = tile_scores.compute() + + all_scores[metric_name] = scores + + # Get dimensions from first metric + first_metric = list(all_scores.keys())[0] + scores = all_scores[first_metric] + tiles_y, tiles_x = scores.shape + + # Use original tile dimensions since padding ensures divisibility + actual_ty = ty + actual_tx = tx + + # Generate keys based on naming convention + table_key = f"qc_img_{image_key}_sharpness" + shapes_key = f"qc_img_{image_key}_sharpness_grid" + + # Create tile polygons and centroids using actual tile dimensions + centroids, polygons = _create_tile_polygons(scores, actual_ty, actual_tx, H, W) + + # Create AnnData object with sharpness scores as variables (genes) + n_tiles = len(centroids) + n_metrics = len(all_scores) + + # Create X matrix with sharpness scores (tiles x metrics) + X_data = np.zeros((n_tiles, n_metrics)) + var_names = [] + for i, (metric_name, metric_scores) in enumerate(all_scores.items()): + X_data[:, i] = metric_scores.ravel() + var_names.append(f"sharpness_{metric_name}") + + # Create tile indices and boundaries using helper function + tile_indices, obs_names, pixel_bounds = _create_tile_indices_and_bounds( + tiles_y, tiles_x, actual_ty, actual_tx, H, W + ) + + # Initialize default values + tissue_mask = np.ones(len(X_data), dtype=bool) + background_mask = np.zeros(len(X_data), dtype=bool) + tissue_similarities = np.zeros(len(X_data), dtype=np.float32) + background_similarities = np.zeros(len(X_data), dtype=np.float32) + outlier_labels = np.ones(len(X_data), dtype=int) # All normal by default + + # Perform outlier detection if requested + if detect_outliers: + print("") + logg.info(f"Detecting outlier tiles using method '{outlier_method}'...") + + if detect_tissue: + logg.info("- Classifying tiles as tissue vs background.") + # Use the already loaded and processed image data + img_da = img_yxc # Already in (y, x, c) format from line 704 + + # Use shared tissue detection function + tissue_mask, background_mask, tissue_similarities, background_similarities = _detect_tissue_rgb( + img_da, tile_indices, tiles_y, tiles_x, actual_ty, actual_tx + ) + n_background = np.sum(background_mask) + n_tissue = np.sum(tissue_mask) + logg.info(f"- Classified {n_background} tiles as background and {n_tissue} tiles as tissue.") + + if detect_tissue and np.sum(tissue_mask) > 0: + logg.info("- Running outlier detection on tissue tiles only.") + tissue_data = X_data[tissue_mask] + outlier_labels_tissue = _detect_sharpness_outliers(tissue_data, method=outlier_method) + + # Map back to all tiles + outlier_labels[tissue_mask] = outlier_labels_tissue + n_outliers = np.sum(outlier_labels == -1) + logg.info(f"- Classified {n_outliers} tiles as outliers ({n_outliers/np.sum(tissue_mask)*100:.1f}%).") + else: + logg.info("- Running outlier detection on all tiles.") + outlier_labels = _detect_sharpness_outliers(X_data, method=outlier_method) + n_outliers = np.sum(outlier_labels == -1) + logg.info(f"- Classified {n_outliers} tiles as outliers ({n_outliers/len(outlier_labels)*100:.1f}%).") + + + adata = AnnData(X=X_data) + adata.var_names = var_names + adata.obs_names = obs_names + + # Add spatial coordinates (centroids) to obs + adata.obs["centroid_y"] = centroids[:, 0] + adata.obs["centroid_x"] = centroids[:, 1] + adata.obsm["spatial"] = centroids + + # Add tissue/background classification and outlier detection results to obs + adata.obs["is_tissue"] = pd.Categorical(tissue_mask.astype(str), categories=["False", "True"]) + adata.obs["is_background"] = pd.Categorical(background_mask.astype(str), categories=["False", "True"]) + adata.obs["sharpness_outlier"] = pd.Categorical((outlier_labels == -1).astype(str), categories=["False", "True"]) + + # Add similarity scores if tissue detection was performed + if detect_tissue: + adata.obs["tissue_similarity"] = tissue_similarities + adata.obs["background_similarity"] = background_similarities + + # Add tile grid indices + adata.obs["tile_y"] = tile_indices[:, 0] + adata.obs["tile_x"] = tile_indices[:, 1] + + # Add tile boundaries in pixels (already calculated by helper function) + adata.obs["pixel_y0"] = pixel_bounds[:, 0] + adata.obs["pixel_x0"] = pixel_bounds[:, 1] + adata.obs["pixel_y1"] = pixel_bounds[:, 2] + adata.obs["pixel_x1"] = pixel_bounds[:, 3] + + # Add metadata + adata.uns["qc_sharpness"] = { + "metrics": list(all_scores.keys()), + "tile_size_y": actual_ty, + "tile_size_x": actual_tx, + "image_height": H, + "image_width": W, + "n_tiles_y": tiles_y, + "n_tiles_x": tiles_x, + "image_key": image_key, + "scale": scale, + "detect_tissue": detect_tissue, + "outlier_method": outlier_method, + "n_tissue_tiles": int(np.sum(tissue_mask)), + "n_background_tiles": int(np.sum(background_mask)), + "n_outlier_tiles": int(np.sum(outlier_labels == -1)), + } + + # Create TableModel and add to SpatialData + table_model = TableModel.parse(adata) + sdata.tables[table_key] = table_model + + logg.info(f"- Stored tiles as sdata.tables['{table_key}'].") + + # Create GeoDataFrame with tile polygons (ensuring complete coverage) + tile_data = [] + for y_idx, x_idx in tile_indices: + y0 = y_idx * actual_ty + x0 = x_idx * actual_tx + y1 = (y_idx + 1) * actual_ty if y_idx < tiles_y - 1 else H + x1 = (x_idx + 1) * actual_tx if x_idx < tiles_x - 1 else W + + # Create rectangular polygon for this tile + polygon = Polygon([ + (x0, y0), # bottom-left + (x1, y0), # bottom-right + (x1, y1), # top-right + (x0, y1), # top-left + (x0, y0) # close polygon + ]) + + # Create tile data (without metrics - they're only in the table) + tile_info = { + 'tile_id': f"tile_x{x_idx}_y{y_idx}", + 'tile_y': y_idx, + 'tile_x': x_idx, + 'pixel_y0': y0, + 'pixel_x0': x0, + 'pixel_y1': y1, + 'pixel_x1': x1, + 'geometry': polygon + } + + tile_data.append(tile_info) + + # Create GeoDataFrame + tile_gdf = gpd.GeoDataFrame(tile_data, geometry='geometry') + + # Create ShapesModel and add to SpatialData + shapes_model = ShapesModel.parse(tile_gdf) + sdata.shapes[shapes_key] = shapes_model + + sdata.tables[table_key].uns["spatialdata_attrs"] = { + "region": shapes_key, + "region_key": "grid_name", + "instance_key": "tile_id", + } + # all the rows of adata annotate the same element, called "spots" (as we declared above) + sdata.tables[table_key].obs["grid_name"] = pd.Categorical([shapes_key] * len(sdata.tables[table_key])) + sdata.tables[table_key].obs["tile_id"] = shapes_model.index + + logg.info(f"- Stored sharpness metrics as sdata.shapes['{shapes_key}'].") diff --git a/src/squidpy/im/_feature.py b/src/squidpy/im/_feature.py index 9694ac48e..0ee429e39 100644 --- a/src/squidpy/im/_feature.py +++ b/src/squidpy/im/_feature.py @@ -6,7 +6,7 @@ import pandas as pd from anndata import AnnData -from scanpy import logging as logg +from spatialdata._logging import logger as logg from squidpy._constants._constants import ImageFeature from squidpy._docs import d, inject_docs From 06be11cbcb325224030facdecfb75d2176e1af15 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Sat, 13 Sep 2025 19:15:22 +0200 Subject: [PATCH 02/12] mvp --- pyproject.toml | 63 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e8968966c..8d7b20c50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,16 +37,20 @@ authors = [ {name = "Giovanni Palla"}, {name = "Michal Klein"}, {name = "Hannah Spitzer"}, + {name = "Tim Treis"}, + {name = "Laurens Lehner"}, + {name = "Selman Ozleyen"}, ] maintainers = [ - {name = "Giovanni Palla", email = "giovanni.palla@helmholtz-muenchen.de"}, - {name = "Michal Klein", email = "michal.klein@helmholtz-muenchen.de"}, - {name = "Tim Treis", email = "tim.treis@helmholtz-muenchen.de"} + {name = "Tim Treis", email = "tim.treis@helmholtz-munich.de"}, + {name = "Selman Ozleyen", email = "selman.ozleyen@helmholtz-munich.de"} ] dependencies = [ "aiohttp>=3.8.1", "anndata>=0.9", + "spatialdata>=0.2.5", + "spatialdata-plot", "cycler>=0.11.0", "dask-image>=0.5.0", "dask[array]>=2021.02.0,<=2024.11.2", @@ -61,7 +65,7 @@ dependencies = [ "pandas>=2.1.0", "Pillow>=8.0.0", "scanpy>=1.9.3", - "scikit-image>=0.20", + "scikit-image>=0.25", # due to https://github.com/scikit-image/scikit-image/issues/6850 breaks rescale ufunc "scikit-learn>=0.24.0", "statsmodels>=0.12.0", @@ -70,21 +74,27 @@ dependencies = [ "tqdm>=4.50.2", "validators>=0.18.2", "xarray>=2024.10.0", - "zarr>=2.6.1,<3.0.0", - "spatialdata>=0.2.5", + "zarr>=2.6.1,<3.0.0", "imagecodecs>=2025.8.2,<2026", ] [project.optional-dependencies] dev = [ "pre-commit>=3.0.0", "hatch>=1.9.0", + "jupyterlab", + "notebook", + "ipykernel", + "ipywidgets", + "jupytext", + "pytest", + "pytest-cov", + "ruff", ] test = [ "scanpy[leiden]", "pytest>=7", "pytest-xdist>=3", "pytest-mock>=3.5.0", - # Just for VS Code "pytest-cov>=4", "coverage[toml]>=7", "pytest-timeout>=2.1.0", @@ -281,4 +291,41 @@ exclude_lines = [ show_missing = true precision = 2 skip_empty = true -sort = "Miss" \ No newline at end of file +sort = "Miss" + +[tool.pixi.workspace] +channels = ["conda-forge"] +platforms = ["osx-arm64", "linux-64"] + +[tool.pixi.dependencies] +python = ">=3.11" + +[tool.pixi.pypi-dependencies] +squidpy = { path = ".", editable = true } + +# for gh-actions +[tool.pixi.feature.py311.dependencies] +python = "3.11.*" + +[tool.pixi.feature.py313.dependencies] +python = "3.13.*" + +[tool.pixi.environments] +# 3.11 lane (for gh-actions) +dev-py311 = { features = ["dev", "test", "py311"], solve-group = "py311" } +docs-py311 = { features = ["docs", "py311"], solve-group = "py311" } + +# 3.12 lane +default = { features = ["py313"], solve-group = "py313" } +dev-py313 = { features = ["dev", "test", "py313"], solve-group = "py313" } +docs-py313 = { features = ["docs", "py313"], solve-group = "py313" } +test-py313 = { features = ["test", "py313"], solve-group = "py313" } + +[tool.pixi.tasks] +lab = "jupyter lab" +kernel-install = "python -m ipykernel install --user --name pixi-dev --display-name \"sdata-plot (dev)\"" +test = "pytest -v --color=yes --tb=short --durations=10" +lint = "ruff check ." +format = "ruff format ." +pre-commit-install = "pre-commit install" +pre-commit-run = "pre-commit run --all-files" From 9ef97fd1f5c8250144589aff5b9cb4dd721b7f2f Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Sun, 14 Sep 2025 03:15:21 +0200 Subject: [PATCH 03/12] for notebook --- src/squidpy/exp/im/_qc.py | 800 ++++++++++++++++++++++++++++++-------- 1 file changed, 648 insertions(+), 152 deletions(-) diff --git a/src/squidpy/exp/im/_qc.py b/src/squidpy/exp/im/_qc.py index 54657e4e6..8132013bf 100644 --- a/src/squidpy/exp/im/_qc.py +++ b/src/squidpy/exp/im/_qc.py @@ -1,11 +1,11 @@ import numpy as np import dask.array as da -import itertools +import enum import xarray as xr from dask.diagnostics import ProgressBar from scipy import ndimage as ndi -from scipy.stats import entropy from scipy.fft import fft2, fftfreq +import itertools from typing import Literal, Optional, Tuple, Union from sklearn.preprocessing import StandardScaler from spatialdata._logging import logger as logg @@ -15,6 +15,11 @@ from shapely.geometry import Polygon import geopandas as gpd from enum import Enum +from numba import njit +import numba + +# Disable Numba parallelization globally to avoid conflicts with Dask +numba.set_num_threads(1) def _ensure_yxc(img_da: xr.DataArray) -> xr.DataArray: """ @@ -24,11 +29,63 @@ def _ensure_yxc(img_da: xr.DataArray) -> xr.DataArray: dims = list(img_da.dims) if "y" not in dims or "x" not in dims: raise ValueError(f"Expected dims to include \"y\" and \"x\". Found dims={dims}") - if "c" in dims: + + # Handle case where dims are (c, y, x) - transpose to (y, x, c) + if "c" in dims and dims[0] == "c": return img_da.transpose("y", "x", "c") + elif "c" in dims: + return img_da.transpose("y", "x", "c") + + # If no "c" dimension, add one return img_da.expand_dims({"c": [0]}).transpose("y", "x", "c") +def _get_image_data(sdata, image_key: str, scale: str = "scale0"): + """ + Extract image data from SpatialData object, handling both datatree and direct DataArray images. + + Parameters + ---------- + sdata : SpatialData + SpatialData object + image_key : str + Key in sdata.images + scale : str + Multiscale level, e.g. "scale0" + + Returns + ------- + xr.DataArray + Image data in (y, x, c) format + """ + img_node = sdata.images[image_key] + + # Check if the image is a datatree (has multiple scales) or a direct DataArray + if hasattr(img_node, 'keys') and len(img_node.keys()) > 1: + # It's a datatree with multiple scales + available_scales = list(img_node.keys()) + + if scale not in available_scales: + logg.warning(f"Scale '{scale}' not found. Available scales: {available_scales}") + if available_scales: + scale = available_scales[0] # Use first available scale + logg.info(f"Using scale '{scale}' instead") + else: + raise ValueError(f"No scales available for image '{image_key}'") + + img_da = img_node[scale].image + else: + # It's a direct DataArray (single scale) + if hasattr(img_node, 'image'): + img_da = img_node.image + else: + # The node itself is the DataArray + img_da = img_node + + # Ensure proper format + return _ensure_yxc(img_da) + + def _to_gray_dask_yx(img_yxc: xr.DataArray, weights=(0.2126, 0.7152, 0.0722)) -> da.Array: """ Dask-native grayscale conversion. Expects (y, x, c). @@ -46,6 +103,53 @@ def _to_gray_dask_yx(img_yxc: xr.DataArray, weights=(0.2126, 0.7152, 0.0722)) -> return gray.astype(np.float32, copy=False) +@njit(fastmath=True, cache=True, parallel=False) +def _sobel_energy_numba(block_2d: np.ndarray) -> np.ndarray: + """ + Numba-optimized kernel for Sobel energy (Tenengrad): gx^2 + gy^2. + Simplified version for better memory efficiency. + Returns per-pixel energy values (not normalized by tile size). + """ + h, w = block_2d.shape + out = np.empty_like(block_2d, dtype=np.float32) + + # Simplified Sobel implementation with boundary handling + for i in range(h): + for j in range(w): + # Calculate Gx (vertical gradient) - simplified + gx = 0.0 + if j > 0 and j < w - 1: + # Use central difference for vertical gradient + gx = block_2d[i, j+1] - block_2d[i, j-1] + elif j == 0: + gx = block_2d[i, j+1] - block_2d[i, j] + else: # j == w - 1 + gx = block_2d[i, j] - block_2d[i, j-1] + + # Calculate Gy (horizontal gradient) - simplified + gy = 0.0 + if i > 0 and i < h - 1: + # Use central difference for horizontal gradient + gy = block_2d[i+1, j] - block_2d[i-1, j] + elif i == 0: + gy = block_2d[i+1, j] - block_2d[i, j] + else: # i == h - 1 + gy = block_2d[i, j] - block_2d[i-1, j] + + # Calculate Sobel energy: gx^2 + gy^2 + energy = gx * gx + gy * gy + + # Clip to prevent overflow + if energy > 1e6: + energy = 1e6 + elif energy < 0.0: + energy = 0.0 + + out[i, j] = energy + + return out + + def _sobel_energy_np(block_2d: np.ndarray) -> np.ndarray: """ NumPy kernel for Sobel energy (Tenengrad): gx^2 + gy^2. @@ -56,54 +160,121 @@ def _sobel_energy_np(block_2d: np.ndarray) -> np.ndarray: return out -def _laplace_square_np(block_2d: np.ndarray) -> np.ndarray: +@njit(fastmath=True, cache=True, parallel=False) +def _laplace_square_numba(block_2d: np.ndarray) -> np.ndarray: """ - NumPy kernel for squared Laplacian. + Numba-optimized kernel for variance of Laplacian. + Calculates the Laplacian at each pixel, then returns the variance of all Laplacian values. """ - lap = ndi.laplace(block_2d, mode="reflect").astype(np.float32) - return lap * lap + h, w = block_2d.shape + n = h * w + + # First pass: calculate Laplacian at each pixel + laplacian_values = np.empty((h, w), dtype=np.float32) + + for i in range(h): + for j in range(w): + # Calculate Laplacian using second differences + lap = 0.0 + + # Horizontal second difference + if j > 0 and j < w - 1: + lap += block_2d[i, j+1] - 2.0 * block_2d[i, j] + block_2d[i, j-1] + elif j == 0: + lap += block_2d[i, j+1] - block_2d[i, j] + else: # j == w - 1 + lap += block_2d[i, j] - block_2d[i, j-1] + + # Vertical second difference + if i > 0 and i < h - 1: + lap += block_2d[i+1, j] - 2.0 * block_2d[i, j] + block_2d[i-1, j] + elif i == 0: + lap += block_2d[i+1, j] - block_2d[i, j] + else: # i == h - 1 + lap += block_2d[i, j] - block_2d[i-1, j] + + laplacian_values[i, j] = lap + + # Second pass: calculate mean of Laplacian values + total = 0.0 + for i in range(h): + for j in range(w): + total += laplacian_values[i, j] + mean_lap = total / n + + # Third pass: calculate variance of Laplacian values + var_sum = 0.0 + for i in range(h): + for j in range(w): + diff = laplacian_values[i, j] - mean_lap + var_sum += diff * diff + var_lap = var_sum / n + + # Clip to prevent overflow + if var_lap > 1e6: + var_lap = 1e6 + elif var_lap < 0.0: + var_lap = 0.0 + + # Fill output array with variance value + out = np.empty_like(block_2d, dtype=np.float32) + for i in range(h): + for j in range(w): + out[i, j] = var_lap + + return out -def _variance_np(block_2d: np.ndarray) -> np.ndarray: +def _laplace_square_np(block_2d: np.ndarray) -> np.ndarray: """ - NumPy kernel for variance metric. - Returns the variance of the block as a constant array. + NumPy kernel for variance of Laplacian. """ - var_val = np.var(block_2d, dtype=np.float32) - return np.full_like(block_2d, var_val, dtype=np.float32) + lap = ndi.laplace(block_2d, mode="reflect").astype(np.float32) + var_lap = np.var(lap, dtype=np.float32) + return np.full_like(block_2d, var_lap, dtype=np.float32) -def _modified_laplacian_np(block_2d: np.ndarray) -> np.ndarray: +@njit(fastmath=True, cache=True, parallel=False) +def _variance_numba(block_2d: np.ndarray) -> np.ndarray: """ - NumPy kernel for Sum of Modified Laplacian. - Uses absolute values of second-order derivatives. + Numba-optimized kernel for variance metric. + Returns the variance of the block as a constant array. """ - # Calculate second-order derivatives - dxx = ndi.convolve1d(block_2d, [1, -2, 1], axis=1, mode="reflect") - dyy = ndi.convolve1d(block_2d, [1, -2, 1], axis=0, mode="reflect") + h, w = block_2d.shape + n = h * w - # Sum of absolute values of second-order derivatives - modified_lap = np.abs(dxx) + np.abs(dyy) - return modified_lap.astype(np.float32) + # Calculate mean + total = 0.0 + for i in range(h): + for j in range(w): + total += block_2d[i, j] + mean_val = total / n + + # Calculate variance + var_sum = 0.0 + for i in range(h): + for j in range(w): + diff = block_2d[i, j] - mean_val + var_sum += diff * diff + var_val = var_sum / n + + + # Fill output array with variance value + out = np.empty_like(block_2d, dtype=np.float32) + for i in range(h): + for j in range(w): + out[i, j] = var_val + + return out -def _entropy_histogram_np(block_2d: np.ndarray) -> np.ndarray: +def _variance_np(block_2d: np.ndarray) -> np.ndarray: """ - NumPy kernel for entropy histogram metric. - Returns the entropy of the intensity histogram as a constant array. + NumPy kernel for variance metric. + Returns the variance of the block as a constant array. """ - # Calculate histogram (256 bins for 8-bit images) - hist, _ = np.histogram(block_2d, bins=256, range=(0, 256)) - hist = hist.astype(np.float32) - - # Normalize histogram to probabilities - hist = hist / np.sum(hist) - - # Calculate entropy (avoid log(0) by adding small epsilon) - hist = hist + 1e-10 - entropy_val = entropy(hist) - - return np.full_like(block_2d, entropy_val, dtype=np.float32) + var_val = np.var(block_2d, dtype=np.float32) + return np.full_like(block_2d, var_val, dtype=np.float32) def _fft_high_freq_energy_np(block_2d: np.ndarray) -> np.ndarray: @@ -111,8 +282,18 @@ def _fft_high_freq_energy_np(block_2d: np.ndarray) -> np.ndarray: NumPy kernel for FFT high-frequency energy ratio. Calculates ratio of high-frequency energy to total energy. """ + # Normalize input to prevent overflow + block_normalized = block_2d.astype(np.float64) # Use float64 for precision + block_mean = np.mean(block_normalized) + block_std = np.std(block_normalized) + + if block_std > 0: + block_normalized = (block_normalized - block_mean) / block_std + else: + block_normalized = block_normalized - block_mean + # Compute 2D FFT - fft_coeffs = fft2(block_2d.astype(np.float32)) + fft_coeffs = fft2(block_normalized) fft_magnitude = np.abs(fft_coeffs) # Create frequency grids @@ -126,16 +307,23 @@ def _fft_high_freq_energy_np(block_2d: np.ndarray) -> np.ndarray: # Use 10% of Nyquist frequency as threshold high_freq_mask = freq_radius > 0.1 - # Calculate energies - total_energy = np.sum(fft_magnitude**2) - high_freq_energy = np.sum((fft_magnitude**2)[high_freq_mask]) + # Calculate energies with overflow protection + fft_mag_sq = fft_magnitude**2 + total_energy = np.sum(fft_mag_sq) + high_freq_energy = np.sum(fft_mag_sq[high_freq_mask]) - # Calculate ratio (avoid division by zero) - if total_energy > 0: + # Calculate ratio (avoid division by zero and extreme values) + if total_energy > 1e-10 and np.isfinite(total_energy) and np.isfinite(high_freq_energy): ratio = high_freq_energy / total_energy + # Clip to reasonable range to prevent extreme values + ratio = np.clip(ratio, 0.0, 1.0) else: ratio = 0.0 + # Ensure finite value + if not np.isfinite(ratio): + ratio = 0.0 + return np.full_like(block_2d, ratio, dtype=np.float32) @@ -146,9 +334,19 @@ def _haar_wavelet_energy_np(block_2d: np.ndarray) -> np.ndarray: Implements manual 2D Haar wavelet transform. """ try: + # Normalize input to prevent overflow + block_normalized = block_2d.astype(np.float64) # Use float64 for precision + block_mean = np.mean(block_normalized) + block_std = np.std(block_normalized) + + if block_std > 0: + block_normalized = (block_normalized - block_mean) / block_std + else: + block_normalized = block_normalized - block_mean + # Ensure even dimensions for Haar wavelet - h, w = block_2d.shape - data = block_2d.astype(np.float32) + h, w = block_normalized.shape + data = block_normalized.copy() # Pad to even dimensions if needed if h % 2 == 1: @@ -174,13 +372,22 @@ def _haar_wavelet_energy_np(block_2d: np.ndarray) -> np.ndarray: cV = (cH_h[:, ::2] + cH_h[:, 1::2]) / 2 # HL (vertical detail) cD = (cH_h[:, ::2] - cH_h[:, 1::2]) / 2 # HH (diagonal detail) - # Calculate energies - total_energy = np.sum(cA**2) + np.sum(cH**2) + np.sum(cV**2) + np.sum(cD**2) - detail_energy = np.sum(cH**2) + np.sum(cV**2) + np.sum(cD**2) # LH, HL, HH bands + # Calculate energies with overflow protection + cA_sq = cA**2 + cH_sq = cH**2 + cV_sq = cV**2 + cD_sq = cD**2 + + total_energy = np.sum(cA_sq) + np.sum(cH_sq) + np.sum(cV_sq) + np.sum(cD_sq) + detail_energy = np.sum(cH_sq) + np.sum(cV_sq) + np.sum(cD_sq) # LH, HL, HH bands # Calculate normalized detail energy ratio - if total_energy > 0: + if (total_energy > 1e-10 and + np.isfinite(total_energy) and + np.isfinite(detail_energy)): ratio = detail_energy / total_energy + # Clip to reasonable range to prevent extreme values + ratio = np.clip(ratio, 0.0, 1.0) else: ratio = 0.0 @@ -188,6 +395,10 @@ def _haar_wavelet_energy_np(block_2d: np.ndarray) -> np.ndarray: # Fallback if wavelet transform fails ratio = 0.0 + # Ensure finite value + if not np.isfinite(ratio): + ratio = 0.0 + return np.full_like(block_2d, ratio, dtype=np.float32) @@ -338,12 +549,8 @@ def detect_tissue(sdata, image_key: str, scale: str = "scale0", tile_size: Union """ from spatialdata._logging import logger as logg - # Get image data - img_node = sdata.images[image_key][scale] - img_da = img_node.image - - # Ensure image is in (y, x, c) format - img_da = _ensure_yxc(img_da) + # Get image data using helper function + img_da = _get_image_data(sdata, image_key, scale) H, W, C = img_da.shape logg.info(f"Preparing sharpness metrics calculation.") @@ -413,7 +620,7 @@ def detect_tissue(sdata, image_key: str, scale: str = "scale0", tile_size: Union return adata -def _detect_sharpness_outliers(X: np.ndarray, method: str = "iqr") -> np.ndarray: +def _detect_sharpness_outliers(X: np.ndarray, method: str = "iqr", tissue_mask: Optional[np.ndarray] = None, var_names: Optional[list] = None) -> np.ndarray: """ Detect tiles with low sharpness (blurry/out-of-focus) using parameter-free methods. @@ -422,23 +629,70 @@ def _detect_sharpness_outliers(X: np.ndarray, method: str = "iqr") -> np.ndarray X : np.ndarray Sharpness metrics array of shape (n_tiles, n_metrics) method : str - Method to use: "iqr" or "zscore" (both parameter-free) + Method to use: "iqr", "zscore", "tenengrad_tissue", or "pvalue" + tissue_mask : np.ndarray, optional + Boolean mask indicating tissue tiles. Required for "tenengrad_tissue" method. Returns: -------- outlier_labels : np.ndarray Array of -1 (low sharpness outlier) or 1 (normal sharpness) labels """ + # Clean the data to prevent infinite values + X_clean = _clean_sharpness_data(X) + + if method == "tenengrad_tissue": + return _detect_tenengrad_tissue_outliers(X_clean, tissue_mask, var_names) + elif method == "pvalue": + return _detect_outliers_pvalue(X_clean, tissue_mask, var_names) + # Standardize the features scaler = StandardScaler() - X_scaled = scaler.fit_transform(X) + X_scaled = scaler.fit_transform(X_clean) if method == "iqr": return _detect_outliers_iqr(X_scaled) elif method == "zscore": return _detect_outliers_zscore(X_scaled) else: - raise ValueError(f"Unknown method: {method}. Use 'iqr' or 'zscore'.") + raise ValueError(f"Unknown method: {method}. Use 'iqr', 'zscore', 'tenengrad_tissue', or 'pvalue'.") + + +def _clean_sharpness_data(X: np.ndarray) -> np.ndarray: + """ + Clean sharpness data by handling infinite and extreme values. + + Parameters: + ----------- + X : np.ndarray + Sharpness metrics array of shape (n_tiles, n_metrics) + + Returns: + -------- + np.ndarray + Cleaned array with finite values + """ + X_clean = X.copy() + + # Replace infinite values with NaN + X_clean[np.isinf(X_clean)] = np.nan + + # For each metric, replace NaN values with the median of that metric + for i in range(X_clean.shape[1]): + metric_data = X_clean[:, i] + if np.any(np.isnan(metric_data)): + median_val = np.nanmedian(metric_data) + X_clean[np.isnan(metric_data), i] = median_val + + # Clip extreme values to prevent overflow + # Use 99.9th percentile as upper bound + for i in range(X_clean.shape[1]): + metric_data = X_clean[:, i] + upper_bound = np.percentile(metric_data, 99.9) + lower_bound = np.percentile(metric_data, 0.1) + X_clean[:, i] = np.clip(metric_data, lower_bound, upper_bound) + + return X_clean def _detect_outliers_iqr(X_scaled: np.ndarray) -> np.ndarray: @@ -477,6 +731,224 @@ def _detect_outliers_zscore(X_scaled: np.ndarray, threshold: float = 3.0) -> np. return outlier_labels +def _detect_tenengrad_tissue_outliers( + X: np.ndarray, + tissue_mask: Optional[np.ndarray] = None, + var_names: Optional[list] = None +) -> np.ndarray: + """ + Calculate unfocus likelihood scores for tissue tiles using Tenengrad scores with background reference. + + Logic: + 1. Use background tiles as reference for "blurry" appearance + 2. Compare tissue tiles to both background and other tissue tiles + 3. Calculate continuous scores (0-1) where higher values indicate more likely to be unfocused + 4. Scores are normalized: 0 = very sharp, 1 = very blurry (background-like) + + Parameters + ---------- + X : np.ndarray + Sharpness scores array of shape (n_tiles, n_metrics) + tissue_mask : np.ndarray, optional + Boolean mask indicating tissue tiles + var_names : list, optional + Variable names for the metrics + + Returns + ------- + np.ndarray + Array of unfocus likelihood scores (0-1) for all tiles + """ + from sklearn.mixture import GaussianMixture + + if tissue_mask is None: + # If no tissue mask, fall back to standard zscore method + return _detect_outliers_zscore(X) + + # Get background and tissue tiles + background_mask = ~tissue_mask + tissue_tiles = X[tissue_mask] + background_tiles = X[background_mask] + + if len(tissue_tiles) == 0 or len(background_tiles) == 0: + return np.zeros(len(X)) # No unfocus if no tissue or background + + # Find Tenengrad metric index + tenengrad_idx = None + for i, var_name in enumerate(var_names): + if "tenengrad" in var_name.lower(): + tenengrad_idx = i + break + + if tenengrad_idx is None: + # Fallback to standard zscore method if Tenengrad not found + return _detect_outliers_zscore(X) + + # Extract Tenengrad scores using the correct index + tissue_tenengrad = tissue_tiles[:, tenengrad_idx] + background_tenengrad = background_tiles[:, tenengrad_idx] + + # Step 1: Analyze background distribution + background_mean = np.mean(background_tenengrad) + background_std = np.std(background_tenengrad) + + # Step 2: Analyze tissue distribution + tissue_mean = np.mean(tissue_tenengrad) + tissue_std = np.std(tissue_tenengrad) + + # Step 3: Determine if tissue has one or two classes + # Use Gaussian Mixture Model to test for bimodality + if len(tissue_tenengrad) >= 4: # Need at least 4 samples for GMM + # Try 1-component and 2-component models + gmm_1 = GaussianMixture(n_components=1, random_state=42) + gmm_2 = GaussianMixture(n_components=2, random_state=42) + + try: + gmm_1.fit(tissue_tenengrad.reshape(-1, 1)) + gmm_2.fit(tissue_tenengrad.reshape(-1, 1)) + + # Use BIC to determine best model + bic_1 = gmm_1.bic(tissue_tenengrad.reshape(-1, 1)) + bic_2 = gmm_2.bic(tissue_tenengrad.reshape(-1, 1)) + + # If 2-component model is significantly better, use it + bic_improvement = bic_1 - bic_2 + use_two_components = bic_improvement > 10 # Threshold for significant improvement + + except: + # If GMM fails, use simple threshold method + use_two_components = False + else: + use_two_components = False + + # Step 4: Calculate continuous unfocus likelihood scores + if use_two_components: + # Two-class case: use GMM probabilities + gmm_2.fit(tissue_tenengrad.reshape(-1, 1)) + tissue_probs = gmm_2.predict_proba(tissue_tenengrad.reshape(-1, 1)) + + # Determine which component is "blurry" (closer to background) + component_means = gmm_2.means_.flatten() + blurry_component = np.argmin(np.abs(component_means - background_mean)) + + # Use probability of belonging to blurry component as unfocus score + unfocus_scores = tissue_probs[:, blurry_component] + + else: + # One-class case: calculate distance-based scores + # Normalize tissue scores relative to background distribution + # Score = 1 - (tissue_score - background_min) / (background_max - background_min) + background_min = np.min(background_tenengrad) + background_max = np.max(background_tenengrad) + background_range = background_max - background_min + + if background_range > 0: + # Normalize tissue scores to [0, 1] where 0 = sharp, 1 = background-like + normalized_scores = (tissue_tenengrad - background_min) / background_range + # Invert so higher Tenengrad = lower unfocus score + unfocus_scores = 1.0 - np.clip(normalized_scores, 0, 1) + else: + # If background has no variation, use simple threshold + tissue_background_ratio = tissue_mean / (background_mean + 1e-10) + if tissue_background_ratio > 1.5: + # Tissue is sharp - score based on distance from tissue mean + unfocus_scores = np.clip((tissue_mean - tissue_tenengrad) / (tissue_std + 1e-10), 0, 1) + else: + # Tissue is blurry - score based on similarity to background + unfocus_scores = np.clip((tissue_tenengrad - background_mean) / (background_std + 1e-10), 0, 1) + + # Step 5: Create full unfocus scores array + full_scores = np.zeros(len(X)) # Start with all sharp (0) + tissue_indices = np.where(tissue_mask)[0] + full_scores[tissue_indices] = unfocus_scores # Set tissue unfocus scores + + return full_scores + + +def _detect_outliers_pvalue( + X: np.ndarray, + tissue_mask: Optional[np.ndarray] = None, + var_names: Optional[list] = None, + alpha: float = 0.05 +) -> Tuple[np.ndarray, np.ndarray]: + """ + Detect outliers using p-value method based on background distribution. + + For each tile, calculates the p-value of observing its sharpness score + given the distribution of background tiles. Tiles with p-values below alpha + are considered outliers (unlikely to come from background distribution). + + Parameters: + ----------- + X : np.ndarray + Sharpness metrics array of shape (n_tiles, n_metrics) + tissue_mask : np.ndarray, optional + Boolean mask indicating tissue tiles. If None, uses all tiles. + var_names : list, optional + List of metric names. If None, uses all metrics. + alpha : float + Significance level for outlier detection (default: 0.05) + + Returns: + -------- + outlier_labels : np.ndarray + Array of -1 (outlier) or 1 (normal) labels + p_values : np.ndarray + Array of p-values for each tile + """ + from scipy import stats + + if tissue_mask is None: + # If no tissue mask, use all tiles + tissue_mask = np.ones(len(X), dtype=bool) + + if var_names is None: + var_names = [f"metric_{i}" for i in range(X.shape[1])] + + # Separate tissue and background tiles + tissue_tiles = X[tissue_mask] + background_tiles = X[~tissue_mask] + + if len(background_tiles) < 10: + # Not enough background tiles for reliable statistics + return np.ones(len(X), dtype=int), np.ones(len(X)) + + # Calculate p-values for each metric + p_values = np.ones((len(tissue_tiles), len(var_names))) + + for i, var_name in enumerate(var_names): + tissue_scores = tissue_tiles[:, i] + background_scores = background_tiles[:, i] + + # Fit normal distribution to background scores + bg_mean = np.mean(background_scores) + bg_std = np.std(background_scores) + + if bg_std < 1e-10: + # No variation in background, skip this metric + continue + + # Calculate p-values for tissue scores + # For sharpness metrics, we typically want to detect LOW values (blurry tiles) + # So we calculate the probability of observing a value as low or lower + p_values[:, i] = stats.norm.cdf(tissue_scores, loc=bg_mean, scale=bg_std) + + # Combine p-values across metrics using Fisher's method or minimum p-value + # Using minimum p-value approach (more conservative) + min_p_values = np.min(p_values, axis=1) + + # Create full p-values array + full_p_values = np.ones(len(X)) + tissue_indices = np.where(tissue_mask)[0] + full_p_values[tissue_indices] = min_p_values + + # Flag outliers: p-value < alpha (unlikely to come from background) + outlier_mask = full_p_values < alpha + + # Convert to -1/1 format + outlier_labels = np.where(outlier_mask, -1, 1) + + return outlier_labels, full_p_values def _calculate_auto_tile_size(height: int, width: int) -> Tuple[int, int]: @@ -498,19 +970,16 @@ def _calculate_auto_tile_size(height: int, width: int) -> Tuple[int, int]: """ # Calculate how many tiles we want (roughly 100 in the larger dimension) target_tiles = 100 - + # Calculate tile size for each dimension tile_size_y = height // target_tiles tile_size_x = width // target_tiles - + # Use the smaller tile size to ensure both dimensions are covered tile_size = min(tile_size_y, tile_size_x) - + # Ensure minimum 100x100 pixel tiles - if tile_size < 100: - return 100, 100 - - return tile_size, tile_size + return (100, 100) if tile_size < 100 else (tile_size, tile_size) def _make_tiles( @@ -654,25 +1123,23 @@ def _create_tile_polygons( return np.array(centroids), polygons class SHARPNESS_METRICS(Enum): - TENENGRAD = "tenengrad" - LAPLACIAN = "laplacian" - VARIANCE = "variance" - MODIFIED_LAPLACIAN = "modified_laplacian" - ENTROPY_HISTOGRAM = "entropy_histogram" - FFT_HIGH_FREQ_ENERGY = "fft_high_freq_energy" - HAAR_WAVELET_ENERGY = "haar_wavelet_energy" - + TENENGRAD = enum.auto() + VAR_OF_LAPLACIAN = enum.auto() + VARIANCE = enum.auto() + FFT_HIGH_FREQ_ENERGY = enum.auto() + HAAR_WAVELET_ENERGY = enum.auto() def qc_sharpness( sdata, image_key: str, scale: str = "scale0", - metrics: Union[SHARPNESS_METRICS, Literal["all"]] = "all", + metrics: SHARPNESS_METRICS | list[SHARPNESS_METRICS] = [SHARPNESS_METRICS.TENENGRAD, SHARPNESS_METRICS.VAR_OF_LAPLACIAN], tile_size: Union[Literal["auto"], Tuple[int, int]] = "auto", - progress: bool = True, detect_outliers: bool = True, detect_tissue: bool = True, - outlier_method: str = "zscore", + outlier_method: str = "pvalue", + outlier_cutoff: float = 0.1, + progress: bool = True, ) -> None: """ Compute tilewise sharpness scores for multiple metrics and print the values. @@ -690,8 +1157,6 @@ def qc_sharpness( tile_size : "auto" or (int, int) Tile size dimensions. When "auto", creates roughly 100x100 grid overlay with minimum 100x100 pixel tiles. - progress : bool - Show a Dask progress bar for the compute step. detect_outliers : bool If True, identify tiles with abnormal sharpness scores. If `detect_tissue=True`, outlier detection will only run on tissue tiles. Adds `sharpness_outlier` column @@ -702,8 +1167,17 @@ def qc_sharpness( references. Adds `is_tissue`, `is_background`, `tissue_similarity`, and `background_similarity` columns to the AnnData table. outlier_method : str - Method for detecting low sharpness tiles: "iqr" or "zscore" (both parameter-free). - Default "zscore" uses Z-score method. Only flags tiles with LOW sharpness. + Method for detecting low sharpness tiles: "iqr", "zscore", "tenengrad_tissue", or "pvalue". + - "iqr": Interquartile Range method (parameter-free) + - "zscore": Z-score method (parameter-free) + - "tenengrad_tissue": Tenengrad-based method using background reference (requires detect_tissue=True) + - "pvalue": P-value method based on background distribution (requires detect_tissue=True, falls back to zscore if not available) + Default "pvalue" uses P-value-based method. Only flags tiles with LOW sharpness. + outlier_cutoff : float + Threshold for binarizing continuous unfocus scores into outlier labels. + Tiles with unfocus_score >= outlier_cutoff are marked as outliers. + progress : bool + Show a Dask progress bar for the compute step. Returns ------- @@ -716,9 +1190,10 @@ def qc_sharpness( "is_background", "tissue_similarity", and "background_similarity" columns. "sharpness_outlier" flags tiles with low sharpness (blurry/out-of-focus). """ - # 1) Get Dask-backed image (no materialization) - img_node = sdata.images[image_key][scale] - img_da = img_node.image + from spatialdata import SpatialData + + # 1) Get image data using helper function + img_da = _get_image_data(sdata, image_key, scale) # 2) Ensure dims and grayscale img_yxc = _ensure_yxc(img_da) @@ -731,7 +1206,7 @@ def qc_sharpness( ty, tx = _make_tiles(gray, tile_size) logg.info(f"Preparing sharpness metrics calculation.") logg.info(f"- Image size (x, y) is ({W}, {H}), using tiles with size (x, y): ({tx}, {ty}).") - + # Calculate tile grid dimensions tiles_y = (H + ty - 1) // ty tiles_x = (W + tx - 1) // tx @@ -743,58 +1218,59 @@ def qc_sharpness( print("") logg.info(f"Calculating sharpness metrics.") - metrics_to_compute = list(SHARPNESS_METRICS) if metrics == "all" else [metrics] + metrics_to_compute = metrics if isinstance(metrics, list) else [metrics] for metric in metrics_to_compute: - metric_name = metric.value if isinstance(metric, SHARPNESS_METRICS) else metric + metric_name = metric.name.lower() if isinstance(metric, SHARPNESS_METRICS) else metric logg.info(f"- Computing sharpness metric '{metric_name}'.") + # Force Dask to use smaller chunks that match tile size + # This prevents large chunks from creating uniform blocks + gray_rechunked = gray.rechunk((ty, tx)) + # Per-pixel sharpness via map_overlap (no overlap for adjacent tiles) if metric_name == "tenengrad": sharp_field = da.map_overlap( - _sobel_energy_np, gray, depth=0, boundary="reflect", dtype=np.float32 + _sobel_energy_numba, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 ) - elif metric_name == "laplacian": + elif metric_name == "var_of_laplacian": sharp_field = da.map_overlap( - _laplace_square_np, gray, depth=0, boundary="reflect", dtype=np.float32 + _laplace_square_numba, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 ) elif metric_name == "variance": sharp_field = da.map_overlap( - _variance_np, gray, depth=0, boundary="reflect", dtype=np.float32 - ) - elif metric_name == "modified_laplacian": - sharp_field = da.map_overlap( - _modified_laplacian_np, gray, depth=0, boundary="reflect", dtype=np.float32 - ) - elif metric_name == "entropy_histogram": - sharp_field = da.map_overlap( - _entropy_histogram_np, gray, depth=0, boundary="reflect", dtype=np.float32 + _variance_numba, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 ) elif metric_name == "fft_high_freq_energy": sharp_field = da.map_overlap( - _fft_high_freq_energy_np, gray, depth=0, boundary="reflect", dtype=np.float32 + _fft_high_freq_energy_np, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 ) elif metric_name == "haar_wavelet_energy": sharp_field = da.map_overlap( - _haar_wavelet_energy_np, gray, depth=0, boundary="reflect", dtype=np.float32 + _haar_wavelet_energy_np, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 ) else: - raise ValueError(f"- Unknown metric {metric_name}.") + raise ValueError(f"- Unknown metric '{metric_name}'.") - # Use the tile dimensions calculated earlier (lines 716-717) - # tiles_y and tiles_x are already calculated and correct - # Pad the array to make it divisible by tile size pad_y = (tiles_y * ty) - sharp_field.shape[0] pad_x = (tiles_x * tx) - sharp_field.shape[1] - + if pad_y > 0 or pad_x > 0: # Pad with edge values padded = da.pad(sharp_field, ((0, pad_y), (0, pad_x)), mode='edge') else: padded = sharp_field - + # Now coarsen with trim_excess=False since dimensions are divisible - tile_scores = da.coarsen(np.mean, padded, {0: ty, 1: tx}, trim_excess=False) + # For additive metrics, we need to normalize by tile area to make them tile-size independent + if metric_name in ["tenengrad"]: + # Sum across tile, then normalize by tile area + tile_scores = da.coarsen(np.sum, padded, {0: ty, 1: tx}, trim_excess=False) + tile_area = ty * tx + tile_scores = tile_scores / tile_area + else: + # For other metrics (variance, entropy, fft, haar), use mean (already normalized) + tile_scores = da.coarsen(np.mean, padded, {0: ty, 1: tx}, trim_excess=False) # Compute scores with progress bar if requested if progress: @@ -809,7 +1285,7 @@ def qc_sharpness( first_metric = list(all_scores.keys())[0] scores = all_scores[first_metric] tiles_y, tiles_x = scores.shape - + # Use original tile dimensions since padding ensures divisibility actual_ty = ty actual_tx = tx @@ -838,74 +1314,98 @@ def qc_sharpness( ) # Initialize default values - tissue_mask = np.ones(len(X_data), dtype=bool) - background_mask = np.zeros(len(X_data), dtype=bool) - tissue_similarities = np.zeros(len(X_data), dtype=np.float32) - background_similarities = np.zeros(len(X_data), dtype=np.float32) outlier_labels = np.ones(len(X_data), dtype=int) # All normal by default - + + adata = AnnData(X=X_data) + adata.var_names = var_names + adata.obs_names = obs_names + adata.obs["centroid_y"] = centroids[:, 0] + adata.obs["centroid_x"] = centroids[:, 1] + adata.obsm["spatial"] = centroids + # Perform outlier detection if requested if detect_outliers: print("") logg.info(f"Detecting outlier tiles using method '{outlier_method}'...") - + if detect_tissue: logg.info("- Classifying tiles as tissue vs background.") + tissue_mask = np.ones(len(X_data), dtype=bool) + background_mask = np.zeros(len(X_data), dtype=bool) + tissue_similarities = np.zeros(len(X_data), dtype=np.float32) + background_similarities = np.zeros(len(X_data), dtype=np.float32) + # Use the already loaded and processed image data img_da = img_yxc # Already in (y, x, c) format from line 704 - + # Use shared tissue detection function tissue_mask, background_mask, tissue_similarities, background_similarities = _detect_tissue_rgb( img_da, tile_indices, tiles_y, tiles_x, actual_ty, actual_tx ) n_background = np.sum(background_mask) n_tissue = np.sum(tissue_mask) + logg.info(f"- Classified {n_background} tiles as background and {n_tissue} tiles as tissue.") - + + # Outlier detection if detect_tissue and np.sum(tissue_mask) > 0: logg.info("- Running outlier detection on tissue tiles only.") - tissue_data = X_data[tissue_mask] - outlier_labels_tissue = _detect_sharpness_outliers(tissue_data, method=outlier_method) - - # Map back to all tiles - outlier_labels[tissue_mask] = outlier_labels_tissue + if outlier_method == "pvalue": + # Use p-value method with tissue mask + outlier_labels, pvalues = _detect_outliers_pvalue(X_data, tissue_mask=tissue_mask, var_names=var_names) + # Convert p-values to unfocus scores (0 = focused, 1 = unfocused) + # Lower p-values (more unlikely to be background) = higher unfocus scores + unfocus_scores = 1.0 - pvalues + min_score = np.min(unfocus_scores) + max_score = np.max(unfocus_scores) + if max_score > min_score: # Avoid division by zero + unfocus_scores = (unfocus_scores - min_score) / (max_score - min_score) + else: + unfocus_scores = np.zeros_like(unfocus_scores) + # Use outlier_cutoff to binarize unfocus scores + outlier_labels = np.where(unfocus_scores >= outlier_cutoff, -1, 1) + elif outlier_method == "tenengrad_tissue": + # Use the new Tenengrad-based method with tissue mask (returns continuous scores) + unfocus_scores = _detect_tenengrad_tissue_outliers(X_data, tissue_mask=tissue_mask, var_names=var_names) + # Use outlier_cutoff to binarize unfocus scores + outlier_labels = np.where(unfocus_scores >= outlier_cutoff, -1, 1) + else: + # Use standard method on tissue tiles only + tissue_data = X_data[tissue_mask] + unfocus_scores = _detect_sharpness_outliers(tissue_data, method=outlier_method) + + # Map back to all tiles + outlier_labels[tissue_mask] = unfocus_scores + n_outliers = np.sum(outlier_labels == -1) logg.info(f"- Classified {n_outliers} tiles as outliers ({n_outliers/np.sum(tissue_mask)*100:.1f}%).") else: logg.info("- Running outlier detection on all tiles.") - outlier_labels = _detect_sharpness_outliers(X_data, method=outlier_method) + if outlier_method == "pvalue": + # P-value method requires tissue detection, fall back to zscore + logg.warning("P-value method requires tissue detection. Falling back to zscore method.") + outlier_labels = _detect_sharpness_outliers(X_data, method="zscore", var_names=var_names) + unfocus_scores = None + else: + outlier_labels = _detect_sharpness_outliers(X_data, method=outlier_method, var_names=var_names) + n_outliers = np.sum(outlier_labels == -1) logg.info(f"- Classified {n_outliers} tiles as outliers ({n_outliers/len(outlier_labels)*100:.1f}%).") + unfocus_scores = None # No continuous scores for standard methods + if detect_tissue: + adata.obs["is_tissue"] = pd.Categorical(tissue_mask.astype(str), categories=["False", "True"]) + adata.obs["is_background"] = pd.Categorical(background_mask.astype(str), categories=["False", "True"]) - adata = AnnData(X=X_data) - adata.var_names = var_names - adata.obs_names = obs_names - - # Add spatial coordinates (centroids) to obs - adata.obs["centroid_y"] = centroids[:, 0] - adata.obs["centroid_x"] = centroids[:, 1] - adata.obsm["spatial"] = centroids - - # Add tissue/background classification and outlier detection results to obs - adata.obs["is_tissue"] = pd.Categorical(tissue_mask.astype(str), categories=["False", "True"]) - adata.obs["is_background"] = pd.Categorical(background_mask.astype(str), categories=["False", "True"]) - adata.obs["sharpness_outlier"] = pd.Categorical((outlier_labels == -1).astype(str), categories=["False", "True"]) - - # Add similarity scores if tissue detection was performed - if detect_tissue: adata.obs["tissue_similarity"] = tissue_similarities adata.obs["background_similarity"] = background_similarities + adata.obs["sharpness_outlier"] = pd.Categorical((outlier_labels == -1).astype(str), categories=["False", "True"]) + + # Add continuous unfocus scores if available + if unfocus_scores is not None: + adata.obs["unfocus_score"] = unfocus_scores - # Add tile grid indices - adata.obs["tile_y"] = tile_indices[:, 0] - adata.obs["tile_x"] = tile_indices[:, 1] - # Add tile boundaries in pixels (already calculated by helper function) - adata.obs["pixel_y0"] = pixel_bounds[:, 0] - adata.obs["pixel_x0"] = pixel_bounds[:, 1] - adata.obs["pixel_y1"] = pixel_bounds[:, 2] - adata.obs["pixel_x1"] = pixel_bounds[:, 3] # Add metadata adata.uns["qc_sharpness"] = { @@ -925,7 +1425,7 @@ def qc_sharpness( "n_outlier_tiles": int(np.sum(outlier_labels == -1)), } - # Create TableModel and add to SpatialData + # Add results to SpatialData table_model = TableModel.parse(adata) sdata.tables[table_key] = table_model @@ -962,19 +1462,15 @@ def qc_sharpness( tile_data.append(tile_info) - # Create GeoDataFrame tile_gdf = gpd.GeoDataFrame(tile_data, geometry='geometry') - - # Create ShapesModel and add to SpatialData shapes_model = ShapesModel.parse(tile_gdf) sdata.shapes[shapes_key] = shapes_model - + sdata.tables[table_key].uns["spatialdata_attrs"] = { "region": shapes_key, "region_key": "grid_name", "instance_key": "tile_id", } - # all the rows of adata annotate the same element, called "spots" (as we declared above) sdata.tables[table_key].obs["grid_name"] = pd.Categorical([shapes_key] * len(sdata.tables[table_key])) sdata.tables[table_key].obs["tile_id"] = shapes_model.index From affeff33b839d5090a36fb9cf6465f1f2fc39471 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Sun, 14 Sep 2025 18:03:26 +0200 Subject: [PATCH 04/12] added small plotting func --- src/squidpy/exp/__init__.py | 5 +- src/squidpy/exp/pl/__init__.py | 3 + src/squidpy/exp/pl/_qc.py | 165 +++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 src/squidpy/exp/pl/__init__.py create mode 100644 src/squidpy/exp/pl/_qc.py diff --git a/src/squidpy/exp/__init__.py b/src/squidpy/exp/__init__.py index 571e3ccb5..3f71410f8 100644 --- a/src/squidpy/exp/__init__.py +++ b/src/squidpy/exp/__init__.py @@ -9,4 +9,7 @@ from . import im from .im._qc import qc_sharpness -__all__ = ["qc_sharpness"] \ No newline at end of file +from . import pl +from .pl._qc import qc_sharpness_metrics + +__all__ = ["qc_sharpness", "qc_sharpness_metrics"] \ No newline at end of file diff --git a/src/squidpy/exp/pl/__init__.py b/src/squidpy/exp/pl/__init__.py new file mode 100644 index 000000000..bd4561cc9 --- /dev/null +++ b/src/squidpy/exp/pl/__init__.py @@ -0,0 +1,3 @@ +from ._qc import qc_sharpness_metrics + +__all__ = ["qc_sharpness_metrics"] \ No newline at end of file diff --git a/src/squidpy/exp/pl/_qc.py b/src/squidpy/exp/pl/_qc.py new file mode 100644 index 000000000..4897bdec7 --- /dev/null +++ b/src/squidpy/exp/pl/_qc.py @@ -0,0 +1,165 @@ +import matplotlib.pyplot as plt +import numpy as np +from typing import Optional, Tuple +from spatialdata._logging import logger as logg +from squidpy.exp.im._qc import SHARPNESS_METRICS + + + +def qc_sharpness_metrics( + sdata, + image_key: str, + metrics: Optional[SHARPNESS_METRICS | list[SHARPNESS_METRICS]] = None, + figsize: Optional[Tuple[int, int]] = None, + return_fig: bool = False, + **kwargs +) -> Optional[plt.Figure]: + """ + Plot a summary view of raw sharpness metrics from qc_sharpness results. + + Automatically scans adata.uns for calculated metrics and plots the raw sharpness values. + Creates a multi-panel plot: one panel per calculated sharpness metric. + Each panel shows: spatial view, histogram, and statistics. + + Parameters + ---------- + sdata : SpatialData + SpatialData object containing QC results. + image_key : str + Image key used in qc_sharpness function. + metrics : SHARPNESS_METRICS or list of SHARPNESS_METRICS, optional + Specific metrics to plot. If None, plots all calculated sharpness metrics. + Use SHARPNESS_METRICS enum values. + figsize : tuple, optional + Figure size (width, height). Auto-calculated if None. + return_fig : bool + Whether to return the figure object. Default is False. + **kwargs + Additional arguments passed to render_shapes(). + + Returns + ------- + fig : matplotlib.Figure or None + The matplotlib figure object if return_fig=True, otherwise None. + """ + import matplotlib.pyplot as plt + + # Expected keys + table_key = f"qc_img_{image_key}_sharpness" + shapes_key = f"qc_img_{image_key}_sharpness_grid" + + if table_key not in sdata.tables: + raise ValueError(f"No QC data found for image '{image_key}'. Run sq.exp.im.qc_sharpness() first.") + + adata = sdata.tables[table_key] + + # Check if qc_sharpness metadata exists + if "qc_sharpness" not in adata.uns: + raise ValueError(f"No qc_sharpness metadata found. Run sq.exp.im.qc_sharpness() first.") + + # Get calculated metrics from metadata + calculated_metrics = adata.uns["qc_sharpness"]["metrics"] + + if not calculated_metrics: + raise ValueError(f"No sharpness metrics found in metadata.") + + # Filter for specific metrics if requested + if metrics is not None: + # Convert metrics to list if single metric provided + metrics_list = metrics if isinstance(metrics, list) else [metrics] + # Convert enum to string names using the same logic as main function + metrics_to_plot = [] + for metric in metrics_list: + metric_name = metric.name.lower() if isinstance(metric, SHARPNESS_METRICS) else metric + if metric_name not in calculated_metrics: + raise ValueError(f"Metric '{metric_name}' not found. Available: {calculated_metrics}") + metrics_to_plot.append(metric_name) + else: + metrics_to_plot = calculated_metrics + + logg.info(f"Plotting {len(metrics_to_plot)} sharpness metrics: {metrics_to_plot}") + + # Create subplots: 3 columns, one row per metric + n_metrics = len(metrics_to_plot) + ncols = 3 # spatial, histogram, stats + nrows = n_metrics + + if figsize is None: + figsize = (12, 4 * nrows) # 12 width for 3 columns, 4 height per row + + fig, axes = plt.subplots(nrows, ncols, figsize=figsize) + + # Ensure axes is always 2D array for consistent indexing + if nrows == 1: + axes = axes.reshape(1, -1) + if ncols == 1: + axes = axes.reshape(-1, 1) + + # Plot each metric + for i, metric_name in enumerate(metrics_to_plot): + # Find the metric in adata.var_names and get raw values + var_name = f"sharpness_{metric_name}" + if var_name not in adata.var_names: + logg.warning(f"Variable '{var_name}' not found in adata.var_names. Skipping.") + continue + + # Get metric index and raw values + metric_idx = list(adata.var_names).index(var_name) + raw_values = adata.X[:, metric_idx] + + # Get axes for this metric (row i, columns 0, 1, 2) + ax_spatial = axes[i, 0] + ax_hist = axes[i, 1] + ax_stats = axes[i, 2] + + # Panel 1: Spatial plot + try: + ( + sdata + .pl.render_shapes(shapes_key, color=var_name, **kwargs) + .pl.show(ax=ax_spatial, title=f"{metric_name.replace('_', ' ').title()}") + ) + except Exception as e: + logg.warning(f"Error plotting spatial view for {metric_name}: {e}") + ax_spatial.text(0.5, 0.5, f"Error plotting\n{metric_name}", + ha='center', va='center', transform=ax_spatial.transAxes) + ax_spatial.set_title(f"{metric_name.replace('_', ' ').title()}") + + # Panel 2: Histogram + ax_hist.hist(raw_values, bins=50, alpha=0.7, edgecolor='black') + ax_hist.set_xlabel(f"{metric_name.replace('_', ' ').title()}") + ax_hist.set_ylabel('Count') + ax_hist.set_title('Distribution') + ax_hist.grid(True, alpha=0.3) + + # Panel 3: Statistics + ax_stats.axis('off') + stats_text = f""" + Raw {metric_name.replace('_', ' ').title()} Statistics: + + Count: {len(raw_values):,} + Mean: {np.mean(raw_values):.4f} + Std: {np.std(raw_values):.4f} + Min: {np.min(raw_values):.4f} + Max: {np.max(raw_values):.4f} + + Percentiles: + 5%: {np.percentile(raw_values, 5):.4f} + 25%: {np.percentile(raw_values, 25):.4f} + 50%: {np.percentile(raw_values, 50):.4f} + 75%: {np.percentile(raw_values, 75):.4f} + 95%: {np.percentile(raw_values, 95):.4f} + + Non-zero: {np.count_nonzero(raw_values):,} + Zero: {np.sum(raw_values == 0):,} + """ + + ax_stats.text( + 0.05, 0.95, stats_text.strip(), + transform=ax_stats.transAxes, fontsize=9, + verticalalignment='top', fontfamily='monospace' + ) + + plt.tight_layout() + + return fig if return_fig else None From db9f34a586bbe65998e7f42c378cd1f7180d5e9f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 16:08:29 +0000 Subject: [PATCH 05/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/__init__.py | 2 +- src/squidpy/exp/__init__.py | 6 +- src/squidpy/exp/im/__init__.py | 6 +- src/squidpy/exp/im/_qc.py | 518 ++++++++++++++++----------------- src/squidpy/exp/pl/__init__.py | 4 +- src/squidpy/exp/pl/_qc.py | 96 +++--- 6 files changed, 320 insertions(+), 312 deletions(-) diff --git a/src/squidpy/__init__.py b/src/squidpy/__init__.py index 6298d9227..cfba804fd 100644 --- a/src/squidpy/__init__.py +++ b/src/squidpy/__init__.py @@ -3,7 +3,7 @@ from importlib import metadata from importlib.metadata import PackageMetadata -from squidpy import datasets, gr, im, pl, read, tl, exp +from squidpy import datasets, exp, gr, im, pl, read, tl try: md: PackageMetadata = metadata.metadata(__name__) diff --git a/src/squidpy/exp/__init__.py b/src/squidpy/exp/__init__.py index 3f71410f8..c9d122df1 100644 --- a/src/squidpy/exp/__init__.py +++ b/src/squidpy/exp/__init__.py @@ -6,10 +6,8 @@ from __future__ import annotations -from . import im +from . import im, pl from .im._qc import qc_sharpness - -from . import pl from .pl._qc import qc_sharpness_metrics -__all__ = ["qc_sharpness", "qc_sharpness_metrics"] \ No newline at end of file +__all__ = ["qc_sharpness", "qc_sharpness_metrics"] diff --git a/src/squidpy/exp/im/__init__.py b/src/squidpy/exp/im/__init__.py index b896344f9..e17cc1c29 100644 --- a/src/squidpy/exp/im/__init__.py +++ b/src/squidpy/exp/im/__init__.py @@ -1,3 +1,5 @@ -from ._qc import qc_sharpness, detect_tissue +from __future__ import annotations -__all__ = ["qc_sharpness", "detect_tissue"] \ No newline at end of file +from ._qc import detect_tissue, qc_sharpness + +__all__ = ["qc_sharpness", "detect_tissue"] diff --git a/src/squidpy/exp/im/_qc.py b/src/squidpy/exp/im/_qc.py index 8132013bf..da836ce8c 100644 --- a/src/squidpy/exp/im/_qc.py +++ b/src/squidpy/exp/im/_qc.py @@ -1,26 +1,30 @@ -import numpy as np -import dask.array as da +from __future__ import annotations + import enum +import itertools +from enum import Enum +from typing import Literal, Optional, Tuple, Union + +import dask.array as da +import geopandas as gpd +import numba +import numpy as np +import pandas as pd import xarray as xr +from anndata import AnnData from dask.diagnostics import ProgressBar +from numba import njit from scipy import ndimage as ndi from scipy.fft import fft2, fftfreq -import itertools -from typing import Literal, Optional, Tuple, Union +from shapely.geometry import Polygon from sklearn.preprocessing import StandardScaler from spatialdata._logging import logger as logg -import pandas as pd -from anndata import AnnData -from spatialdata.models import TableModel, ShapesModel -from shapely.geometry import Polygon -import geopandas as gpd -from enum import Enum -from numba import njit -import numba +from spatialdata.models import ShapesModel, TableModel # Disable Numba parallelization globally to avoid conflicts with Dask numba.set_num_threads(1) + def _ensure_yxc(img_da: xr.DataArray) -> xr.DataArray: """ Ensure dims are (y, x, c). SpatialData often uses (c, y, x). @@ -28,14 +32,14 @@ def _ensure_yxc(img_da: xr.DataArray) -> xr.DataArray: """ dims = list(img_da.dims) if "y" not in dims or "x" not in dims: - raise ValueError(f"Expected dims to include \"y\" and \"x\". Found dims={dims}") - + raise ValueError(f'Expected dims to include "y" and "x". Found dims={dims}') + # Handle case where dims are (c, y, x) - transpose to (y, x, c) if "c" in dims and dims[0] == "c": return img_da.transpose("y", "x", "c") elif "c" in dims: return img_da.transpose("y", "x", "c") - + # If no "c" dimension, add one return img_da.expand_dims({"c": [0]}).transpose("y", "x", "c") @@ -43,7 +47,7 @@ def _ensure_yxc(img_da: xr.DataArray) -> xr.DataArray: def _get_image_data(sdata, image_key: str, scale: str = "scale0"): """ Extract image data from SpatialData object, handling both datatree and direct DataArray images. - + Parameters ---------- sdata : SpatialData @@ -52,19 +56,19 @@ def _get_image_data(sdata, image_key: str, scale: str = "scale0"): Key in sdata.images scale : str Multiscale level, e.g. "scale0" - + Returns ------- xr.DataArray Image data in (y, x, c) format """ img_node = sdata.images[image_key] - + # Check if the image is a datatree (has multiple scales) or a direct DataArray - if hasattr(img_node, 'keys') and len(img_node.keys()) > 1: + if hasattr(img_node, "keys") and len(img_node.keys()) > 1: # It's a datatree with multiple scales available_scales = list(img_node.keys()) - + if scale not in available_scales: logg.warning(f"Scale '{scale}' not found. Available scales: {available_scales}") if available_scales: @@ -72,16 +76,16 @@ def _get_image_data(sdata, image_key: str, scale: str = "scale0"): logg.info(f"Using scale '{scale}' instead") else: raise ValueError(f"No scales available for image '{image_key}'") - + img_da = img_node[scale].image else: # It's a direct DataArray (single scale) - if hasattr(img_node, 'image'): + if hasattr(img_node, "image"): img_da = img_node.image else: # The node itself is the DataArray img_da = img_node - + # Ensure proper format return _ensure_yxc(img_da) @@ -112,7 +116,7 @@ def _sobel_energy_numba(block_2d: np.ndarray) -> np.ndarray: """ h, w = block_2d.shape out = np.empty_like(block_2d, dtype=np.float32) - + # Simplified Sobel implementation with boundary handling for i in range(h): for j in range(w): @@ -120,33 +124,33 @@ def _sobel_energy_numba(block_2d: np.ndarray) -> np.ndarray: gx = 0.0 if j > 0 and j < w - 1: # Use central difference for vertical gradient - gx = block_2d[i, j+1] - block_2d[i, j-1] + gx = block_2d[i, j + 1] - block_2d[i, j - 1] elif j == 0: - gx = block_2d[i, j+1] - block_2d[i, j] + gx = block_2d[i, j + 1] - block_2d[i, j] else: # j == w - 1 - gx = block_2d[i, j] - block_2d[i, j-1] - + gx = block_2d[i, j] - block_2d[i, j - 1] + # Calculate Gy (horizontal gradient) - simplified gy = 0.0 if i > 0 and i < h - 1: # Use central difference for horizontal gradient - gy = block_2d[i+1, j] - block_2d[i-1, j] + gy = block_2d[i + 1, j] - block_2d[i - 1, j] elif i == 0: - gy = block_2d[i+1, j] - block_2d[i, j] + gy = block_2d[i + 1, j] - block_2d[i, j] else: # i == h - 1 - gy = block_2d[i, j] - block_2d[i-1, j] - + gy = block_2d[i, j] - block_2d[i - 1, j] + # Calculate Sobel energy: gx^2 + gy^2 energy = gx * gx + gy * gy - + # Clip to prevent overflow if energy > 1e6: energy = 1e6 elif energy < 0.0: energy = 0.0 - + out[i, j] = energy - + return out @@ -168,40 +172,40 @@ def _laplace_square_numba(block_2d: np.ndarray) -> np.ndarray: """ h, w = block_2d.shape n = h * w - + # First pass: calculate Laplacian at each pixel laplacian_values = np.empty((h, w), dtype=np.float32) - + for i in range(h): for j in range(w): # Calculate Laplacian using second differences lap = 0.0 - + # Horizontal second difference if j > 0 and j < w - 1: - lap += block_2d[i, j+1] - 2.0 * block_2d[i, j] + block_2d[i, j-1] + lap += block_2d[i, j + 1] - 2.0 * block_2d[i, j] + block_2d[i, j - 1] elif j == 0: - lap += block_2d[i, j+1] - block_2d[i, j] + lap += block_2d[i, j + 1] - block_2d[i, j] else: # j == w - 1 - lap += block_2d[i, j] - block_2d[i, j-1] - + lap += block_2d[i, j] - block_2d[i, j - 1] + # Vertical second difference if i > 0 and i < h - 1: - lap += block_2d[i+1, j] - 2.0 * block_2d[i, j] + block_2d[i-1, j] + lap += block_2d[i + 1, j] - 2.0 * block_2d[i, j] + block_2d[i - 1, j] elif i == 0: - lap += block_2d[i+1, j] - block_2d[i, j] + lap += block_2d[i + 1, j] - block_2d[i, j] else: # i == h - 1 - lap += block_2d[i, j] - block_2d[i-1, j] - + lap += block_2d[i, j] - block_2d[i - 1, j] + laplacian_values[i, j] = lap - + # Second pass: calculate mean of Laplacian values total = 0.0 for i in range(h): for j in range(w): total += laplacian_values[i, j] mean_lap = total / n - + # Third pass: calculate variance of Laplacian values var_sum = 0.0 for i in range(h): @@ -209,19 +213,19 @@ def _laplace_square_numba(block_2d: np.ndarray) -> np.ndarray: diff = laplacian_values[i, j] - mean_lap var_sum += diff * diff var_lap = var_sum / n - + # Clip to prevent overflow if var_lap > 1e6: var_lap = 1e6 elif var_lap < 0.0: var_lap = 0.0 - + # Fill output array with variance value out = np.empty_like(block_2d, dtype=np.float32) for i in range(h): for j in range(w): out[i, j] = var_lap - + return out @@ -242,14 +246,14 @@ def _variance_numba(block_2d: np.ndarray) -> np.ndarray: """ h, w = block_2d.shape n = h * w - + # Calculate mean total = 0.0 for i in range(h): for j in range(w): total += block_2d[i, j] mean_val = total / n - + # Calculate variance var_sum = 0.0 for i in range(h): @@ -257,14 +261,13 @@ def _variance_numba(block_2d: np.ndarray) -> np.ndarray: diff = block_2d[i, j] - mean_val var_sum += diff * diff var_val = var_sum / n - - + # Fill output array with variance value out = np.empty_like(block_2d, dtype=np.float32) for i in range(h): for j in range(w): out[i, j] = var_val - + return out @@ -286,32 +289,32 @@ def _fft_high_freq_energy_np(block_2d: np.ndarray) -> np.ndarray: block_normalized = block_2d.astype(np.float64) # Use float64 for precision block_mean = np.mean(block_normalized) block_std = np.std(block_normalized) - + if block_std > 0: block_normalized = (block_normalized - block_mean) / block_std else: block_normalized = block_normalized - block_mean - + # Compute 2D FFT fft_coeffs = fft2(block_normalized) fft_magnitude = np.abs(fft_coeffs) - + # Create frequency grids h, w = block_2d.shape freq_y = fftfreq(h) freq_x = fftfreq(w) - freq_grid_y, freq_grid_x = np.meshgrid(freq_y, freq_x, indexing='ij') + freq_grid_y, freq_grid_x = np.meshgrid(freq_y, freq_x, indexing="ij") freq_radius = np.sqrt(freq_grid_y**2 + freq_grid_x**2) - + # Define high-frequency mask (exclude DC and very low frequencies) # Use 10% of Nyquist frequency as threshold high_freq_mask = freq_radius > 0.1 - + # Calculate energies with overflow protection fft_mag_sq = fft_magnitude**2 total_energy = np.sum(fft_mag_sq) high_freq_energy = np.sum(fft_mag_sq[high_freq_mask]) - + # Calculate ratio (avoid division by zero and extreme values) if total_energy > 1e-10 and np.isfinite(total_energy) and np.isfinite(high_freq_energy): ratio = high_freq_energy / total_energy @@ -319,11 +322,11 @@ def _fft_high_freq_energy_np(block_2d: np.ndarray) -> np.ndarray: ratio = np.clip(ratio, 0.0, 1.0) else: ratio = 0.0 - + # Ensure finite value if not np.isfinite(ratio): ratio = 0.0 - + return np.full_like(block_2d, ratio, dtype=np.float32) @@ -338,74 +341,74 @@ def _haar_wavelet_energy_np(block_2d: np.ndarray) -> np.ndarray: block_normalized = block_2d.astype(np.float64) # Use float64 for precision block_mean = np.mean(block_normalized) block_std = np.std(block_normalized) - + if block_std > 0: block_normalized = (block_normalized - block_mean) / block_std else: block_normalized = block_normalized - block_mean - + # Ensure even dimensions for Haar wavelet h, w = block_normalized.shape data = block_normalized.copy() - + # Pad to even dimensions if needed if h % 2 == 1: data = np.vstack([data, data[-1:, :]]) if w % 2 == 1: data = np.hstack([data, data[:, -1:]]) - + # Manual 2D Haar wavelet transform h_new, w_new = data.shape - + # Step 1: Horizontal decomposition (rows) # Low-pass: (even + odd) / 2 cA_h = (data[::2, :] + data[1::2, :]) / 2 # Approximation rows - # High-pass: (even - odd) / 2 + # High-pass: (even - odd) / 2 cH_h = (data[::2, :] - data[1::2, :]) / 2 # Detail rows - + # Step 2: Vertical decomposition (columns) on both subbands # On approximation subband cA = (cA_h[:, ::2] + cA_h[:, 1::2]) / 2 # LL (approximation) cH = (cA_h[:, ::2] - cA_h[:, 1::2]) / 2 # LH (horizontal detail) - - # On detail subband + + # On detail subband cV = (cH_h[:, ::2] + cH_h[:, 1::2]) / 2 # HL (vertical detail) cD = (cH_h[:, ::2] - cH_h[:, 1::2]) / 2 # HH (diagonal detail) - + # Calculate energies with overflow protection cA_sq = cA**2 cH_sq = cH**2 cV_sq = cV**2 cD_sq = cD**2 - + total_energy = np.sum(cA_sq) + np.sum(cH_sq) + np.sum(cV_sq) + np.sum(cD_sq) detail_energy = np.sum(cH_sq) + np.sum(cV_sq) + np.sum(cD_sq) # LH, HL, HH bands - + # Calculate normalized detail energy ratio - if (total_energy > 1e-10 and - np.isfinite(total_energy) and - np.isfinite(detail_energy)): + if total_energy > 1e-10 and np.isfinite(total_energy) and np.isfinite(detail_energy): ratio = detail_energy / total_energy # Clip to reasonable range to prevent extreme values ratio = np.clip(ratio, 0.0, 1.0) else: ratio = 0.0 - - except Exception as e: + + except Exception: # Fallback if wavelet transform fails ratio = 0.0 - + # Ensure finite value if not np.isfinite(ratio): ratio = 0.0 - + return np.full_like(block_2d, ratio, dtype=np.float32) -def _detect_tissue_rgb(img_da, tile_indices: np.ndarray, tiles_y: int, tiles_x: int, ty: int, tx: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: +def _detect_tissue_rgb( + img_da, tile_indices: np.ndarray, tiles_y: int, tiles_x: int, ty: int, tx: int +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ Internal function to detect tissue vs background using RGB values with Dask optimization. - + Parameters: ----------- img_da : dask.array @@ -416,7 +419,7 @@ def _detect_tissue_rgb(img_da, tile_indices: np.ndarray, tiles_y: int, tiles_x: Grid dimensions ty, tx : int Tile size dimensions - + Returns: -------- tissue_mask : np.ndarray @@ -428,8 +431,8 @@ def _detect_tissue_rgb(img_da, tile_indices: np.ndarray, tiles_y: int, tiles_x: background_similarities : np.ndarray Similarity scores to background reference """ - from spatialdata._logging import logger as logg from dask.diagnostics import ProgressBar + from spatialdata._logging import logger as logg H, W, C = img_da.shape n_tiles = len(tile_indices) @@ -461,10 +464,10 @@ def _detect_tissue_rgb(img_da, tile_indices: np.ndarray, tiles_y: int, tiles_x: # Identify reference tiles # Corner tiles (background reference) corner_positions = [ - (0, 0), # Top-left - (0, tiles_x - 1), # Top-right - (tiles_y - 1, 0), # Bottom-left - (tiles_y - 1, tiles_x - 1) # Bottom-right + (0, 0), # Top-left + (0, tiles_x - 1), # Top-right + (tiles_y - 1, 0), # Bottom-left + (tiles_y - 1, tiles_x - 1), # Bottom-right ] # Center tiles (tissue reference) @@ -522,10 +525,12 @@ def _detect_tissue_rgb(img_da, tile_indices: np.ndarray, tiles_y: int, tiles_x: return tissue_mask, background_mask, tissue_similarities, background_similarities -def detect_tissue(sdata, image_key: str, scale: str = "scale0", tile_size: Union[Literal["auto"], Tuple[int, int]] = "auto") -> AnnData: +def detect_tissue( + sdata, image_key: str, scale: str = "scale0", tile_size: Literal["auto"] | tuple[int, int] = "auto" +) -> AnnData: """ Detect tissue vs background tiles using RGB values from corner (background) vs center (tissue) references. - + Parameters: ----------- sdata : SpatialData @@ -537,7 +542,7 @@ def detect_tissue(sdata, image_key: str, scale: str = "scale0", tile_size: Union tile_size : "auto" or (int, int) Tile size dimensions. When "auto", creates roughly 100x100 grid overlay with minimum 100x100 pixel tiles. - + Returns: -------- AnnData @@ -548,14 +553,14 @@ def detect_tissue(sdata, image_key: str, scale: str = "scale0", tile_size: Union - "background_similarity": Similarity to background reference (0-1) """ from spatialdata._logging import logger as logg - + # Get image data using helper function img_da = _get_image_data(sdata, image_key, scale) H, W, C = img_da.shape - - logg.info(f"Preparing sharpness metrics calculation.") + + logg.info("Preparing sharpness metrics calculation.") logg.info(f"- Image size (x, y) is ({W}, {H}), channels: {C}.") - + # Calculate tile size if tile_size == "auto": ty, tx = _calculate_auto_tile_size(H, W) @@ -563,45 +568,43 @@ def detect_tissue(sdata, image_key: str, scale: str = "scale0", tile_size: Union ty, tx = tile_size if len(tile_size) == 3: raise ValueError("tile_size must be 2D (y, x), not 3D") - + logg.info(f"Using tiles with size (x, y): ({tx}, {ty})") - + # Calculate number of tiles tiles_y = (H + ty - 1) // ty tiles_x = (W + tx - 1) // tx n_tiles = tiles_y * tiles_x - + # Create tile indices and boundaries using helper function - tile_indices, obs_names, pixel_bounds = _create_tile_indices_and_bounds( - tiles_y, tiles_x, ty, tx, H, W - ) - + tile_indices, obs_names, pixel_bounds = _create_tile_indices_and_bounds(tiles_y, tiles_x, ty, tx, H, W) + # Use shared tissue detection function tissue_mask, background_mask, tissue_similarities, background_similarities = _detect_tissue_rgb( img_da, tile_indices, tiles_y, tiles_x, ty, tx ) - + # Create AnnData object adata = AnnData(X=np.zeros((n_tiles, 1))) # Dummy X matrix adata.var_names = ["dummy"] adata.obs_names = obs_names - + # Add tissue classification results adata.obs["is_tissue"] = pd.Categorical(tissue_mask.astype(str), categories=["False", "True"]) adata.obs["is_background"] = pd.Categorical(background_mask.astype(str), categories=["False", "True"]) adata.obs["tissue_similarity"] = tissue_similarities adata.obs["background_similarity"] = background_similarities - + # Add tile grid indices adata.obs["tile_y"] = tile_indices[:, 0] adata.obs["tile_x"] = tile_indices[:, 1] - + # Add tile boundaries in pixels (already calculated by helper function) adata.obs["pixel_y0"] = pixel_bounds[:, 0] adata.obs["pixel_x0"] = pixel_bounds[:, 1] adata.obs["pixel_y1"] = pixel_bounds[:, 2] adata.obs["pixel_x1"] = pixel_bounds[:, 3] - + # Add metadata adata.uns["tissue_detection"] = { "image_key": image_key, @@ -614,16 +617,18 @@ def detect_tissue(sdata, image_key: str, scale: str = "scale0", tile_size: Union "n_tiles_x": tiles_x, "n_tissue_tiles": int(np.sum(tissue_mask)), "n_background_tiles": int(np.sum(background_mask)), - "method": "rgb_similarity" + "method": "rgb_similarity", } - + return adata -def _detect_sharpness_outliers(X: np.ndarray, method: str = "iqr", tissue_mask: Optional[np.ndarray] = None, var_names: Optional[list] = None) -> np.ndarray: +def _detect_sharpness_outliers( + X: np.ndarray, method: str = "iqr", tissue_mask: np.ndarray | None = None, var_names: list | None = None +) -> np.ndarray: """ Detect tiles with low sharpness (blurry/out-of-focus) using parameter-free methods. - + Parameters: ----------- X : np.ndarray @@ -632,7 +637,7 @@ def _detect_sharpness_outliers(X: np.ndarray, method: str = "iqr", tissue_mask: Method to use: "iqr", "zscore", "tenengrad_tissue", or "pvalue" tissue_mask : np.ndarray, optional Boolean mask indicating tissue tiles. Required for "tenengrad_tissue" method. - + Returns: -------- outlier_labels : np.ndarray @@ -640,16 +645,16 @@ def _detect_sharpness_outliers(X: np.ndarray, method: str = "iqr", tissue_mask: """ # Clean the data to prevent infinite values X_clean = _clean_sharpness_data(X) - + if method == "tenengrad_tissue": return _detect_tenengrad_tissue_outliers(X_clean, tissue_mask, var_names) elif method == "pvalue": return _detect_outliers_pvalue(X_clean, tissue_mask, var_names) - + # Standardize the features scaler = StandardScaler() X_scaled = scaler.fit_transform(X_clean) - + if method == "iqr": return _detect_outliers_iqr(X_scaled) elif method == "zscore": @@ -661,29 +666,29 @@ def _detect_sharpness_outliers(X: np.ndarray, method: str = "iqr", tissue_mask: def _clean_sharpness_data(X: np.ndarray) -> np.ndarray: """ Clean sharpness data by handling infinite and extreme values. - + Parameters: ----------- X : np.ndarray Sharpness metrics array of shape (n_tiles, n_metrics) - + Returns: -------- np.ndarray Cleaned array with finite values """ X_clean = X.copy() - + # Replace infinite values with NaN X_clean[np.isinf(X_clean)] = np.nan - + # For each metric, replace NaN values with the median of that metric for i in range(X_clean.shape[1]): metric_data = X_clean[:, i] if np.any(np.isnan(metric_data)): median_val = np.nanmedian(metric_data) X_clean[np.isnan(metric_data), i] = median_val - + # Clip extreme values to prevent overflow # Use 99.9th percentile as upper bound for i in range(X_clean.shape[1]): @@ -691,7 +696,7 @@ def _clean_sharpness_data(X: np.ndarray) -> np.ndarray: upper_bound = np.percentile(metric_data, 99.9) lower_bound = np.percentile(metric_data, 0.1) X_clean[:, i] = np.clip(metric_data, lower_bound, upper_bound) - + return X_clean @@ -701,17 +706,17 @@ def _detect_outliers_iqr(X_scaled: np.ndarray) -> np.ndarray: Q1 = np.percentile(X_scaled, 25, axis=0) Q3 = np.percentile(X_scaled, 75, axis=0) IQR = Q3 - Q1 - + # Define outlier bounds (1.5 * IQR rule) - only lower bound for low sharpness lower_bound = Q1 - 1.5 * IQR - + # Flag outliers only if sharpness is LOW (below lower bound) # High sharpness is good, so we don't flag upper outliers outlier_mask = np.any(X_scaled < lower_bound, axis=1) - + # Convert to -1/1 format outlier_labels = np.where(outlier_mask, -1, 1) - + return outlier_labels @@ -720,31 +725,29 @@ def _detect_outliers_zscore(X_scaled: np.ndarray, threshold: float = 3.0) -> np. # Calculate Z-scores (X_scaled is already standardized) # Only check for LOW sharpness (negative z-scores) z_scores = X_scaled # X_scaled is already standardized - + # Flag tiles that have LOW sharpness (negative z-scores below threshold) # High sharpness (positive z-scores) is good, so we don't flag those outlier_mask = np.any(z_scores < -threshold, axis=1) - + # Convert to -1/1 format outlier_labels = np.where(outlier_mask, -1, 1) - + return outlier_labels def _detect_tenengrad_tissue_outliers( - X: np.ndarray, - tissue_mask: Optional[np.ndarray] = None, - var_names: Optional[list] = None + X: np.ndarray, tissue_mask: np.ndarray | None = None, var_names: list | None = None ) -> np.ndarray: """ Calculate unfocus likelihood scores for tissue tiles using Tenengrad scores with background reference. - + Logic: 1. Use background tiles as reference for "blurry" appearance 2. Compare tissue tiles to both background and other tissue tiles 3. Calculate continuous scores (0-1) where higher values indicate more likely to be unfocused 4. Scores are normalized: 0 = very sharp, 1 = very blurry (background-like) - + Parameters ---------- X : np.ndarray @@ -753,87 +756,87 @@ def _detect_tenengrad_tissue_outliers( Boolean mask indicating tissue tiles var_names : list, optional Variable names for the metrics - + Returns ------- np.ndarray Array of unfocus likelihood scores (0-1) for all tiles """ from sklearn.mixture import GaussianMixture - + if tissue_mask is None: # If no tissue mask, fall back to standard zscore method return _detect_outliers_zscore(X) - + # Get background and tissue tiles background_mask = ~tissue_mask tissue_tiles = X[tissue_mask] background_tiles = X[background_mask] - + if len(tissue_tiles) == 0 or len(background_tiles) == 0: return np.zeros(len(X)) # No unfocus if no tissue or background - + # Find Tenengrad metric index tenengrad_idx = None for i, var_name in enumerate(var_names): if "tenengrad" in var_name.lower(): tenengrad_idx = i break - + if tenengrad_idx is None: # Fallback to standard zscore method if Tenengrad not found return _detect_outliers_zscore(X) - + # Extract Tenengrad scores using the correct index tissue_tenengrad = tissue_tiles[:, tenengrad_idx] background_tenengrad = background_tiles[:, tenengrad_idx] - + # Step 1: Analyze background distribution background_mean = np.mean(background_tenengrad) background_std = np.std(background_tenengrad) - + # Step 2: Analyze tissue distribution tissue_mean = np.mean(tissue_tenengrad) tissue_std = np.std(tissue_tenengrad) - + # Step 3: Determine if tissue has one or two classes # Use Gaussian Mixture Model to test for bimodality if len(tissue_tenengrad) >= 4: # Need at least 4 samples for GMM # Try 1-component and 2-component models gmm_1 = GaussianMixture(n_components=1, random_state=42) gmm_2 = GaussianMixture(n_components=2, random_state=42) - + try: gmm_1.fit(tissue_tenengrad.reshape(-1, 1)) gmm_2.fit(tissue_tenengrad.reshape(-1, 1)) - + # Use BIC to determine best model bic_1 = gmm_1.bic(tissue_tenengrad.reshape(-1, 1)) bic_2 = gmm_2.bic(tissue_tenengrad.reshape(-1, 1)) - + # If 2-component model is significantly better, use it bic_improvement = bic_1 - bic_2 use_two_components = bic_improvement > 10 # Threshold for significant improvement - + except: # If GMM fails, use simple threshold method use_two_components = False else: use_two_components = False - + # Step 4: Calculate continuous unfocus likelihood scores if use_two_components: # Two-class case: use GMM probabilities gmm_2.fit(tissue_tenengrad.reshape(-1, 1)) tissue_probs = gmm_2.predict_proba(tissue_tenengrad.reshape(-1, 1)) - + # Determine which component is "blurry" (closer to background) component_means = gmm_2.means_.flatten() blurry_component = np.argmin(np.abs(component_means - background_mean)) - + # Use probability of belonging to blurry component as unfocus score unfocus_scores = tissue_probs[:, blurry_component] - + else: # One-class case: calculate distance-based scores # Normalize tissue scores relative to background distribution @@ -841,7 +844,7 @@ def _detect_tenengrad_tissue_outliers( background_min = np.min(background_tenengrad) background_max = np.max(background_tenengrad) background_range = background_max - background_min - + if background_range > 0: # Normalize tissue scores to [0, 1] where 0 = sharp, 1 = background-like normalized_scores = (tissue_tenengrad - background_min) / background_range @@ -856,28 +859,25 @@ def _detect_tenengrad_tissue_outliers( else: # Tissue is blurry - score based on similarity to background unfocus_scores = np.clip((tissue_tenengrad - background_mean) / (background_std + 1e-10), 0, 1) - + # Step 5: Create full unfocus scores array full_scores = np.zeros(len(X)) # Start with all sharp (0) tissue_indices = np.where(tissue_mask)[0] full_scores[tissue_indices] = unfocus_scores # Set tissue unfocus scores - + return full_scores def _detect_outliers_pvalue( - X: np.ndarray, - tissue_mask: Optional[np.ndarray] = None, - var_names: Optional[list] = None, - alpha: float = 0.05 -) -> Tuple[np.ndarray, np.ndarray]: + X: np.ndarray, tissue_mask: np.ndarray | None = None, var_names: list | None = None, alpha: float = 0.05 +) -> tuple[np.ndarray, np.ndarray]: """ Detect outliers using p-value method based on background distribution. - + For each tile, calculates the p-value of observing its sharpness score given the distribution of background tiles. Tiles with p-values below alpha are considered outliers (unlikely to come from background distribution). - + Parameters: ----------- X : np.ndarray @@ -888,7 +888,7 @@ def _detect_outliers_pvalue( List of metric names. If None, uses all metrics. alpha : float Significance level for outlier detection (default: 0.05) - + Returns: -------- outlier_labels : np.ndarray @@ -897,72 +897,72 @@ def _detect_outliers_pvalue( Array of p-values for each tile """ from scipy import stats - + if tissue_mask is None: # If no tissue mask, use all tiles tissue_mask = np.ones(len(X), dtype=bool) - + if var_names is None: var_names = [f"metric_{i}" for i in range(X.shape[1])] - + # Separate tissue and background tiles tissue_tiles = X[tissue_mask] background_tiles = X[~tissue_mask] - + if len(background_tiles) < 10: # Not enough background tiles for reliable statistics return np.ones(len(X), dtype=int), np.ones(len(X)) - + # Calculate p-values for each metric p_values = np.ones((len(tissue_tiles), len(var_names))) - + for i, var_name in enumerate(var_names): tissue_scores = tissue_tiles[:, i] background_scores = background_tiles[:, i] - + # Fit normal distribution to background scores bg_mean = np.mean(background_scores) bg_std = np.std(background_scores) - + if bg_std < 1e-10: # No variation in background, skip this metric continue - + # Calculate p-values for tissue scores # For sharpness metrics, we typically want to detect LOW values (blurry tiles) # So we calculate the probability of observing a value as low or lower p_values[:, i] = stats.norm.cdf(tissue_scores, loc=bg_mean, scale=bg_std) - + # Combine p-values across metrics using Fisher's method or minimum p-value # Using minimum p-value approach (more conservative) min_p_values = np.min(p_values, axis=1) - + # Create full p-values array full_p_values = np.ones(len(X)) tissue_indices = np.where(tissue_mask)[0] full_p_values[tissue_indices] = min_p_values - + # Flag outliers: p-value < alpha (unlikely to come from background) outlier_mask = full_p_values < alpha - + # Convert to -1/1 format outlier_labels = np.where(outlier_mask, -1, 1) - + return outlier_labels, full_p_values -def _calculate_auto_tile_size(height: int, width: int) -> Tuple[int, int]: +def _calculate_auto_tile_size(height: int, width: int) -> tuple[int, int]: """ Calculate tile size for auto mode: roughly 100x100 grid overlay with 100px minimum. Creates square tiles that evenly divide the image dimensions. - + Parameters ---------- height : int Image height in pixels width : int Image width in pixels - + Returns ------- Tuple[int, int] @@ -984,47 +984,42 @@ def _calculate_auto_tile_size(height: int, width: int) -> Tuple[int, int]: def _make_tiles( arr_yx: da.Array, - tile_size: Union[Literal["auto"], Tuple[int, int]], -) -> Tuple[int, int]: + tile_size: Literal["auto"] | tuple[int, int], +) -> tuple[int, int]: """ Decide tile size based on tile_size parameter. - + Parameters ---------- arr_yx : da.Array Dask array with (y, x) dimensions tile_size : "auto" or (int, int) Tile size dimensions. When "auto", creates roughly 100x100 grid overlay. - + Returns ------- Tuple[int, int] Tile size as (y, x) in pixels """ - + if tile_size == "auto": return _calculate_auto_tile_size(arr_yx.shape[0], arr_yx.shape[1]) - + if isinstance(tile_size, tuple): if len(tile_size) == 2: return int(tile_size[0]), int(tile_size[1]) else: raise ValueError(f"tile_size tuple must have exactly 2 dimensions (y, x), got {len(tile_size)}") - + raise ValueError(f"tile_size must be 'auto' or a 2-tuple, got {type(tile_size)}") def _create_tile_indices_and_bounds( - tiles_y: int, - tiles_x: int, - tile_size_y: int, - tile_size_x: int, - image_height: int, - image_width: int -) -> Tuple[np.ndarray, list, np.ndarray]: + tiles_y: int, tiles_x: int, tile_size_y: int, tile_size_x: int, image_height: int, image_width: int +) -> tuple[np.ndarray, list, np.ndarray]: """ Create tile indices, observation names, and pixel boundaries. - + Parameters ---------- tiles_y, tiles_x : int @@ -1033,7 +1028,7 @@ def _create_tile_indices_and_bounds( Tile size dimensions image_height, image_width : int Image dimensions - + Returns ------- Tuple[np.ndarray, list, np.ndarray] @@ -1045,32 +1040,28 @@ def _create_tile_indices_and_bounds( tile_indices = [] obs_names = [] pixel_bounds = [] - + for y_idx in range(tiles_y): for x_idx in range(tiles_x): tile_indices.append([y_idx, x_idx]) obs_names.append(f"tile_x{x_idx}_y{y_idx}") - + # Calculate tile boundaries ensuring complete coverage y0 = y_idx * tile_size_y x0 = x_idx * tile_size_x y1 = (y_idx + 1) * tile_size_y if y_idx < tiles_y - 1 else image_height x1 = (x_idx + 1) * tile_size_x if x_idx < tiles_x - 1 else image_width pixel_bounds.append([y0, x0, y1, x1]) - + return np.array(tile_indices), obs_names, np.array(pixel_bounds) def _create_tile_polygons( - scores: np.ndarray, - tile_size_y: int, - tile_size_x: int, - image_height: int, - image_width: int -) -> Tuple[np.ndarray, list]: + scores: np.ndarray, tile_size_y: int, tile_size_x: int, image_height: int, image_width: int +) -> tuple[np.ndarray, list]: """ Create rectangular polygons for each tile in the grid. - + Parameters ---------- scores : np.ndarray @@ -1083,7 +1074,7 @@ def _create_tile_polygons( Total image height in pixels image_width : int Total image width in pixels - + Returns ------- Tuple[np.ndarray, list] @@ -1094,7 +1085,7 @@ def _create_tile_polygons( tiles_y, tiles_x = scores.shape centroids = [] polygons = [] - + for y_idx in range(tiles_y): for x_idx in range(tiles_x): # Calculate tile boundaries in pixel coordinates @@ -1103,25 +1094,28 @@ def _create_tile_polygons( x0 = x_idx * tile_size_x y1 = (y_idx + 1) * tile_size_y if y_idx < tiles_y - 1 else image_height x1 = (x_idx + 1) * tile_size_x if x_idx < tiles_x - 1 else image_width - + # Calculate centroid centroid_y = (y0 + y1) / 2 centroid_x = (x0 + x1) / 2 centroids.append([centroid_y, centroid_x]) - + # Create rectangular polygon for this tile # Note: Polygon expects (x, y) coordinates, not (y, x) - polygon = Polygon([ - (x0, y0), # bottom-left - (x1, y0), # bottom-right - (x1, y1), # top-right - (x0, y1), # top-left - (x0, y0) # close polygon - ]) + polygon = Polygon( + [ + (x0, y0), # bottom-left + (x1, y0), # bottom-right + (x1, y1), # top-right + (x0, y1), # top-left + (x0, y0), # close polygon + ] + ) polygons.append(polygon) - + return np.array(centroids), polygons + class SHARPNESS_METRICS(Enum): TENENGRAD = enum.auto() VAR_OF_LAPLACIAN = enum.auto() @@ -1129,12 +1123,16 @@ class SHARPNESS_METRICS(Enum): FFT_HIGH_FREQ_ENERGY = enum.auto() HAAR_WAVELET_ENERGY = enum.auto() + def qc_sharpness( sdata, image_key: str, scale: str = "scale0", - metrics: SHARPNESS_METRICS | list[SHARPNESS_METRICS] = [SHARPNESS_METRICS.TENENGRAD, SHARPNESS_METRICS.VAR_OF_LAPLACIAN], - tile_size: Union[Literal["auto"], Tuple[int, int]] = "auto", + metrics: SHARPNESS_METRICS | list[SHARPNESS_METRICS] = [ + SHARPNESS_METRICS.TENENGRAD, + SHARPNESS_METRICS.VAR_OF_LAPLACIAN, + ], + tile_size: Literal["auto"] | tuple[int, int] = "auto", detect_outliers: bool = True, detect_tissue: bool = True, outlier_method: str = "pvalue", @@ -1162,19 +1160,19 @@ def qc_sharpness( outlier detection will only run on tissue tiles. Adds `sharpness_outlier` column to the AnnData table. detect_tissue : bool - Only evaluated if `detect_outliers=True`. If True, classify tiles as background - vs tissue using RGB values from corner (background) vs center (tissue) - references. Adds `is_tissue`, `is_background`, `tissue_similarity`, and + Only evaluated if `detect_outliers=True`. If True, classify tiles as background + vs tissue using RGB values from corner (background) vs center (tissue) + references. Adds `is_tissue`, `is_background`, `tissue_similarity`, and `background_similarity` columns to the AnnData table. outlier_method : str Method for detecting low sharpness tiles: "iqr", "zscore", "tenengrad_tissue", or "pvalue". - "iqr": Interquartile Range method (parameter-free) - - "zscore": Z-score method (parameter-free) + - "zscore": Z-score method (parameter-free) - "tenengrad_tissue": Tenengrad-based method using background reference (requires detect_tissue=True) - "pvalue": P-value method based on background distribution (requires detect_tissue=True, falls back to zscore if not available) Default "pvalue" uses P-value-based method. Only flags tiles with LOW sharpness. outlier_cutoff : float - Threshold for binarizing continuous unfocus scores into outlier labels. + Threshold for binarizing continuous unfocus scores into outlier labels. Tiles with unfocus_score >= outlier_cutoff are marked as outliers. progress : bool Show a Dask progress bar for the compute step. @@ -1186,7 +1184,7 @@ def qc_sharpness( Table key: "qc_img_{image_key}_sharpness" Shapes key: "qc_img_{image_key}_sharpness_grid" When `detect_outliers=True`, adds "sharpness_outlier" column to AnnData.obs. - When `detect_tissue=True` and `detect_outliers=True`, also adds "is_tissue", + When `detect_tissue=True` and `detect_outliers=True`, also adds "is_tissue", "is_background", "tissue_similarity", and "background_similarity" columns. "sharpness_outlier" flags tiles with low sharpness (blurry/out-of-focus). """ @@ -1204,7 +1202,7 @@ def qc_sharpness( # 4) Calculate tile size ty, tx = _make_tiles(gray, tile_size) - logg.info(f"Preparing sharpness metrics calculation.") + logg.info("Preparing sharpness metrics calculation.") logg.info(f"- Image size (x, y) is ({W}, {H}), using tiles with size (x, y): ({tx}, {ty}).") # Calculate tile grid dimensions @@ -1217,7 +1215,7 @@ def qc_sharpness( all_scores = {} print("") - logg.info(f"Calculating sharpness metrics.") + logg.info("Calculating sharpness metrics.") metrics_to_compute = metrics if isinstance(metrics, list) else [metrics] for metric in metrics_to_compute: metric_name = metric.name.lower() if isinstance(metric, SHARPNESS_METRICS) else metric @@ -1237,16 +1235,14 @@ def qc_sharpness( _laplace_square_numba, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 ) elif metric_name == "variance": - sharp_field = da.map_overlap( - _variance_numba, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 - ) + sharp_field = da.map_overlap(_variance_numba, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32) elif metric_name == "fft_high_freq_energy": sharp_field = da.map_overlap( - _fft_high_freq_energy_np, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 + _fft_high_freq_energy_np, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 ) elif metric_name == "haar_wavelet_energy": sharp_field = da.map_overlap( - _haar_wavelet_energy_np, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 + _haar_wavelet_energy_np, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 ) else: raise ValueError(f"- Unknown metric '{metric_name}'.") @@ -1257,7 +1253,7 @@ def qc_sharpness( if pad_y > 0 or pad_x > 0: # Pad with edge values - padded = da.pad(sharp_field, ((0, pad_y), (0, pad_x)), mode='edge') + padded = da.pad(sharp_field, ((0, pad_y), (0, pad_x)), mode="edge") else: padded = sharp_field @@ -1378,7 +1374,7 @@ def qc_sharpness( outlier_labels[tissue_mask] = unfocus_scores n_outliers = np.sum(outlier_labels == -1) - logg.info(f"- Classified {n_outliers} tiles as outliers ({n_outliers/np.sum(tissue_mask)*100:.1f}%).") + logg.info(f"- Classified {n_outliers} tiles as outliers ({n_outliers / np.sum(tissue_mask) * 100:.1f}%).") else: logg.info("- Running outlier detection on all tiles.") if outlier_method == "pvalue": @@ -1390,7 +1386,7 @@ def qc_sharpness( outlier_labels = _detect_sharpness_outliers(X_data, method=outlier_method, var_names=var_names) n_outliers = np.sum(outlier_labels == -1) - logg.info(f"- Classified {n_outliers} tiles as outliers ({n_outliers/len(outlier_labels)*100:.1f}%).") + logg.info(f"- Classified {n_outliers} tiles as outliers ({n_outliers / len(outlier_labels) * 100:.1f}%).") unfocus_scores = None # No continuous scores for standard methods if detect_tissue: @@ -1399,14 +1395,14 @@ def qc_sharpness( adata.obs["tissue_similarity"] = tissue_similarities adata.obs["background_similarity"] = background_similarities - adata.obs["sharpness_outlier"] = pd.Categorical((outlier_labels == -1).astype(str), categories=["False", "True"]) + adata.obs["sharpness_outlier"] = pd.Categorical( + (outlier_labels == -1).astype(str), categories=["False", "True"] + ) # Add continuous unfocus scores if available if unfocus_scores is not None: adata.obs["unfocus_score"] = unfocus_scores - - # Add metadata adata.uns["qc_sharpness"] = { "metrics": list(all_scores.keys()), @@ -1440,36 +1436,38 @@ def qc_sharpness( x1 = (x_idx + 1) * actual_tx if x_idx < tiles_x - 1 else W # Create rectangular polygon for this tile - polygon = Polygon([ - (x0, y0), # bottom-left - (x1, y0), # bottom-right - (x1, y1), # top-right - (x0, y1), # top-left - (x0, y0) # close polygon - ]) + polygon = Polygon( + [ + (x0, y0), # bottom-left + (x1, y0), # bottom-right + (x1, y1), # top-right + (x0, y1), # top-left + (x0, y0), # close polygon + ] + ) # Create tile data (without metrics - they're only in the table) tile_info = { - 'tile_id': f"tile_x{x_idx}_y{y_idx}", - 'tile_y': y_idx, - 'tile_x': x_idx, - 'pixel_y0': y0, - 'pixel_x0': x0, - 'pixel_y1': y1, - 'pixel_x1': x1, - 'geometry': polygon + "tile_id": f"tile_x{x_idx}_y{y_idx}", + "tile_y": y_idx, + "tile_x": x_idx, + "pixel_y0": y0, + "pixel_x0": x0, + "pixel_y1": y1, + "pixel_x1": x1, + "geometry": polygon, } tile_data.append(tile_info) - tile_gdf = gpd.GeoDataFrame(tile_data, geometry='geometry') + tile_gdf = gpd.GeoDataFrame(tile_data, geometry="geometry") shapes_model = ShapesModel.parse(tile_gdf) sdata.shapes[shapes_key] = shapes_model sdata.tables[table_key].uns["spatialdata_attrs"] = { "region": shapes_key, - "region_key": "grid_name", - "instance_key": "tile_id", + "region_key": "grid_name", + "instance_key": "tile_id", } sdata.tables[table_key].obs["grid_name"] = pd.Categorical([shapes_key] * len(sdata.tables[table_key])) sdata.tables[table_key].obs["tile_id"] = shapes_model.index diff --git a/src/squidpy/exp/pl/__init__.py b/src/squidpy/exp/pl/__init__.py index bd4561cc9..34781f236 100644 --- a/src/squidpy/exp/pl/__init__.py +++ b/src/squidpy/exp/pl/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from ._qc import qc_sharpness_metrics -__all__ = ["qc_sharpness_metrics"] \ No newline at end of file +__all__ = ["qc_sharpness_metrics"] diff --git a/src/squidpy/exp/pl/_qc.py b/src/squidpy/exp/pl/_qc.py index 4897bdec7..c26753561 100644 --- a/src/squidpy/exp/pl/_qc.py +++ b/src/squidpy/exp/pl/_qc.py @@ -1,26 +1,29 @@ +from __future__ import annotations + +from typing import Optional, Tuple + import matplotlib.pyplot as plt import numpy as np -from typing import Optional, Tuple from spatialdata._logging import logger as logg -from squidpy.exp.im._qc import SHARPNESS_METRICS +from squidpy.exp.im._qc import SHARPNESS_METRICS def qc_sharpness_metrics( sdata, image_key: str, - metrics: Optional[SHARPNESS_METRICS | list[SHARPNESS_METRICS]] = None, - figsize: Optional[Tuple[int, int]] = None, + metrics: SHARPNESS_METRICS | list[SHARPNESS_METRICS] | None = None, + figsize: tuple[int, int] | None = None, return_fig: bool = False, - **kwargs -) -> Optional[plt.Figure]: + **kwargs, +) -> plt.Figure | None: """ Plot a summary view of raw sharpness metrics from qc_sharpness results. - + Automatically scans adata.uns for calculated metrics and plots the raw sharpness values. Creates a multi-panel plot: one panel per calculated sharpness metric. Each panel shows: spatial view, histogram, and statistics. - + Parameters ---------- sdata : SpatialData @@ -36,33 +39,33 @@ def qc_sharpness_metrics( Whether to return the figure object. Default is False. **kwargs Additional arguments passed to render_shapes(). - + Returns ------- fig : matplotlib.Figure or None The matplotlib figure object if return_fig=True, otherwise None. """ import matplotlib.pyplot as plt - + # Expected keys table_key = f"qc_img_{image_key}_sharpness" shapes_key = f"qc_img_{image_key}_sharpness_grid" - + if table_key not in sdata.tables: raise ValueError(f"No QC data found for image '{image_key}'. Run sq.exp.im.qc_sharpness() first.") - + adata = sdata.tables[table_key] - + # Check if qc_sharpness metadata exists if "qc_sharpness" not in adata.uns: - raise ValueError(f"No qc_sharpness metadata found. Run sq.exp.im.qc_sharpness() first.") - + raise ValueError("No qc_sharpness metadata found. Run sq.exp.im.qc_sharpness() first.") + # Get calculated metrics from metadata calculated_metrics = adata.uns["qc_sharpness"]["metrics"] - + if not calculated_metrics: - raise ValueError(f"No sharpness metrics found in metadata.") - + raise ValueError("No sharpness metrics found in metadata.") + # Filter for specific metrics if requested if metrics is not None: # Convert metrics to list if single metric provided @@ -76,25 +79,25 @@ def qc_sharpness_metrics( metrics_to_plot.append(metric_name) else: metrics_to_plot = calculated_metrics - + logg.info(f"Plotting {len(metrics_to_plot)} sharpness metrics: {metrics_to_plot}") - + # Create subplots: 3 columns, one row per metric n_metrics = len(metrics_to_plot) ncols = 3 # spatial, histogram, stats nrows = n_metrics - + if figsize is None: figsize = (12, 4 * nrows) # 12 width for 3 columns, 4 height per row - + fig, axes = plt.subplots(nrows, ncols, figsize=figsize) - + # Ensure axes is always 2D array for consistent indexing if nrows == 1: axes = axes.reshape(1, -1) if ncols == 1: axes = axes.reshape(-1, 1) - + # Plot each metric for i, metric_name in enumerate(metrics_to_plot): # Find the metric in adata.var_names and get raw values @@ -102,40 +105,41 @@ def qc_sharpness_metrics( if var_name not in adata.var_names: logg.warning(f"Variable '{var_name}' not found in adata.var_names. Skipping.") continue - + # Get metric index and raw values metric_idx = list(adata.var_names).index(var_name) raw_values = adata.X[:, metric_idx] - + # Get axes for this metric (row i, columns 0, 1, 2) ax_spatial = axes[i, 0] ax_hist = axes[i, 1] ax_stats = axes[i, 2] - + # Panel 1: Spatial plot try: ( - sdata - .pl.render_shapes(shapes_key, color=var_name, **kwargs) - .pl.show(ax=ax_spatial, title=f"{metric_name.replace('_', ' ').title()}") + sdata.pl.render_shapes(shapes_key, color=var_name, **kwargs).pl.show( + ax=ax_spatial, title=f"{metric_name.replace('_', ' ').title()}" ) + ) except Exception as e: logg.warning(f"Error plotting spatial view for {metric_name}: {e}") - ax_spatial.text(0.5, 0.5, f"Error plotting\n{metric_name}", - ha='center', va='center', transform=ax_spatial.transAxes) + ax_spatial.text( + 0.5, 0.5, f"Error plotting\n{metric_name}", ha="center", va="center", transform=ax_spatial.transAxes + ) ax_spatial.set_title(f"{metric_name.replace('_', ' ').title()}") - + # Panel 2: Histogram - ax_hist.hist(raw_values, bins=50, alpha=0.7, edgecolor='black') + ax_hist.hist(raw_values, bins=50, alpha=0.7, edgecolor="black") ax_hist.set_xlabel(f"{metric_name.replace('_', ' ').title()}") - ax_hist.set_ylabel('Count') - ax_hist.set_title('Distribution') + ax_hist.set_ylabel("Count") + ax_hist.set_title("Distribution") ax_hist.grid(True, alpha=0.3) - + # Panel 3: Statistics - ax_stats.axis('off') + ax_stats.axis("off") stats_text = f""" - Raw {metric_name.replace('_', ' ').title()} Statistics: + Raw {metric_name.replace("_", " ").title()} Statistics: Count: {len(raw_values):,} Mean: {np.mean(raw_values):.4f} @@ -153,13 +157,17 @@ def qc_sharpness_metrics( Non-zero: {np.count_nonzero(raw_values):,} Zero: {np.sum(raw_values == 0):,} """ - + ax_stats.text( - 0.05, 0.95, stats_text.strip(), - transform=ax_stats.transAxes, fontsize=9, - verticalalignment='top', fontfamily='monospace' + 0.05, + 0.95, + stats_text.strip(), + transform=ax_stats.transAxes, + fontsize=9, + verticalalignment="top", + fontfamily="monospace", ) - + plt.tight_layout() return fig if return_fig else None From f06e89a3606d36a1d2685029c9985095d028417b Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 29 Oct 2025 15:15:23 +0100 Subject: [PATCH 06/12] refactor --- src/squidpy/_utils.py | 17 +- src/squidpy/exp/__init__.py | 13 - src/squidpy/exp/im/__init__.py | 5 - src/squidpy/exp/im/_qc.py | 1475 ----------------- src/squidpy/exp/pl/__init__.py | 5 - src/squidpy/experimental/__init__.py | 5 +- src/squidpy/experimental/im/__init__.py | 10 +- src/squidpy/experimental/im/_detect_tissue.py | 8 +- src/squidpy/experimental/im/_qc_sharpness.py | 599 +++++++ .../experimental/im/_sharpness_metrics.py | 188 +++ src/squidpy/experimental/im/_utils.py | 153 +- src/squidpy/experimental/pl/__init__.py | 5 + .../pl/_qc_sharpness.py} | 71 +- tests/experimental/test_detect_tissue.py | 2 - tests/experimental/test_qc_sharpness.py | 45 + 15 files changed, 1028 insertions(+), 1573 deletions(-) delete mode 100644 src/squidpy/exp/__init__.py delete mode 100644 src/squidpy/exp/im/__init__.py delete mode 100644 src/squidpy/exp/im/_qc.py delete mode 100644 src/squidpy/exp/pl/__init__.py create mode 100644 src/squidpy/experimental/im/_qc_sharpness.py create mode 100644 src/squidpy/experimental/im/_sharpness_metrics.py create mode 100644 src/squidpy/experimental/pl/__init__.py rename src/squidpy/{exp/pl/_qc.py => experimental/pl/_qc_sharpness.py} (70%) create mode 100644 tests/experimental/test_qc_sharpness.py diff --git a/src/squidpy/_utils.py b/src/squidpy/_utils.py index 99f1b1348..a12e035c0 100644 --- a/src/squidpy/_utils.py +++ b/src/squidpy/_utils.py @@ -11,12 +11,13 @@ from multiprocessing import Manager, cpu_count from queue import Queue from threading import Thread -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal import joblib as jl import numba import numpy as np import spatialdata as sd +import xarray as xr from spatialdata.models import Image2DModel, Labels2DModel __all__ = ["singledispatchmethod", "Signal", "SigQueue", "NDArray", "NDArrayA"] @@ -373,3 +374,17 @@ def _yx_from_shape(shape: tuple[int, ...]) -> tuple[int, int]: return shape[1], shape[2] raise ValueError(f"Unsupported shape {shape}. Expected (y, x) or (c, y, x).") + + +def _ensure_dim_order(img_da: xr.DataArray, order: Literal["cyx", "yxc"] = "yxc") -> xr.DataArray: + """ + Ensure dims are in the requested order and that a 'c' dim exists. + Only supports images with dims subset of {'y','x','c'}. + """ + dims = list(img_da.dims) + if "y" not in dims or "x" not in dims: + raise ValueError(f'Expected dims to include "y" and "x". Found dims={dims}') + if "c" not in dims: + img_da = img_da.expand_dims({"c": [0]}) + # After possible expand, just transpose to target + return img_da.transpose(*tuple(order)) diff --git a/src/squidpy/exp/__init__.py b/src/squidpy/exp/__init__.py deleted file mode 100644 index c9d122df1..000000000 --- a/src/squidpy/exp/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Experimental module for Squidpy. - -This module contains experimental features that are still under development. -These features may change or be removed in future releases. -""" - -from __future__ import annotations - -from . import im, pl -from .im._qc import qc_sharpness -from .pl._qc import qc_sharpness_metrics - -__all__ = ["qc_sharpness", "qc_sharpness_metrics"] diff --git a/src/squidpy/exp/im/__init__.py b/src/squidpy/exp/im/__init__.py deleted file mode 100644 index e17cc1c29..000000000 --- a/src/squidpy/exp/im/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from ._qc import detect_tissue, qc_sharpness - -__all__ = ["qc_sharpness", "detect_tissue"] diff --git a/src/squidpy/exp/im/_qc.py b/src/squidpy/exp/im/_qc.py deleted file mode 100644 index da836ce8c..000000000 --- a/src/squidpy/exp/im/_qc.py +++ /dev/null @@ -1,1475 +0,0 @@ -from __future__ import annotations - -import enum -import itertools -from enum import Enum -from typing import Literal, Optional, Tuple, Union - -import dask.array as da -import geopandas as gpd -import numba -import numpy as np -import pandas as pd -import xarray as xr -from anndata import AnnData -from dask.diagnostics import ProgressBar -from numba import njit -from scipy import ndimage as ndi -from scipy.fft import fft2, fftfreq -from shapely.geometry import Polygon -from sklearn.preprocessing import StandardScaler -from spatialdata._logging import logger as logg -from spatialdata.models import ShapesModel, TableModel - -# Disable Numba parallelization globally to avoid conflicts with Dask -numba.set_num_threads(1) - - -def _ensure_yxc(img_da: xr.DataArray) -> xr.DataArray: - """ - Ensure dims are (y, x, c). SpatialData often uses (c, y, x). - Adds a length-1 "c" if missing. - """ - dims = list(img_da.dims) - if "y" not in dims or "x" not in dims: - raise ValueError(f'Expected dims to include "y" and "x". Found dims={dims}') - - # Handle case where dims are (c, y, x) - transpose to (y, x, c) - if "c" in dims and dims[0] == "c": - return img_da.transpose("y", "x", "c") - elif "c" in dims: - return img_da.transpose("y", "x", "c") - - # If no "c" dimension, add one - return img_da.expand_dims({"c": [0]}).transpose("y", "x", "c") - - -def _get_image_data(sdata, image_key: str, scale: str = "scale0"): - """ - Extract image data from SpatialData object, handling both datatree and direct DataArray images. - - Parameters - ---------- - sdata : SpatialData - SpatialData object - image_key : str - Key in sdata.images - scale : str - Multiscale level, e.g. "scale0" - - Returns - ------- - xr.DataArray - Image data in (y, x, c) format - """ - img_node = sdata.images[image_key] - - # Check if the image is a datatree (has multiple scales) or a direct DataArray - if hasattr(img_node, "keys") and len(img_node.keys()) > 1: - # It's a datatree with multiple scales - available_scales = list(img_node.keys()) - - if scale not in available_scales: - logg.warning(f"Scale '{scale}' not found. Available scales: {available_scales}") - if available_scales: - scale = available_scales[0] # Use first available scale - logg.info(f"Using scale '{scale}' instead") - else: - raise ValueError(f"No scales available for image '{image_key}'") - - img_da = img_node[scale].image - else: - # It's a direct DataArray (single scale) - if hasattr(img_node, "image"): - img_da = img_node.image - else: - # The node itself is the DataArray - img_da = img_node - - # Ensure proper format - return _ensure_yxc(img_da) - - -def _to_gray_dask_yx(img_yxc: xr.DataArray, weights=(0.2126, 0.7152, 0.0722)) -> da.Array: - """ - Dask-native grayscale conversion. Expects (y, x, c). - For RGBA, ignores alpha; for single-channel, returns it as-is. - """ - arr = img_yxc.data - if arr.ndim != 3: - raise ValueError("Expected a 3D array (y, x, c).") - c = arr.shape[2] - if c == 1: - return arr[..., 0].astype(np.float32, copy=False) - rgb = arr[..., :3].astype(np.float32, copy=False) - w = da.from_array(np.asarray(weights, dtype=np.float32), chunks=(3,)) - gray = da.tensordot(rgb, w, axes=([2], [0])) # -> (y, x) - return gray.astype(np.float32, copy=False) - - -@njit(fastmath=True, cache=True, parallel=False) -def _sobel_energy_numba(block_2d: np.ndarray) -> np.ndarray: - """ - Numba-optimized kernel for Sobel energy (Tenengrad): gx^2 + gy^2. - Simplified version for better memory efficiency. - Returns per-pixel energy values (not normalized by tile size). - """ - h, w = block_2d.shape - out = np.empty_like(block_2d, dtype=np.float32) - - # Simplified Sobel implementation with boundary handling - for i in range(h): - for j in range(w): - # Calculate Gx (vertical gradient) - simplified - gx = 0.0 - if j > 0 and j < w - 1: - # Use central difference for vertical gradient - gx = block_2d[i, j + 1] - block_2d[i, j - 1] - elif j == 0: - gx = block_2d[i, j + 1] - block_2d[i, j] - else: # j == w - 1 - gx = block_2d[i, j] - block_2d[i, j - 1] - - # Calculate Gy (horizontal gradient) - simplified - gy = 0.0 - if i > 0 and i < h - 1: - # Use central difference for horizontal gradient - gy = block_2d[i + 1, j] - block_2d[i - 1, j] - elif i == 0: - gy = block_2d[i + 1, j] - block_2d[i, j] - else: # i == h - 1 - gy = block_2d[i, j] - block_2d[i - 1, j] - - # Calculate Sobel energy: gx^2 + gy^2 - energy = gx * gx + gy * gy - - # Clip to prevent overflow - if energy > 1e6: - energy = 1e6 - elif energy < 0.0: - energy = 0.0 - - out[i, j] = energy - - return out - - -def _sobel_energy_np(block_2d: np.ndarray) -> np.ndarray: - """ - NumPy kernel for Sobel energy (Tenengrad): gx^2 + gy^2. - """ - gx = ndi.sobel(block_2d, axis=0, mode="reflect") - gy = ndi.sobel(block_2d, axis=1, mode="reflect") - out = gx.astype(np.float32) ** 2 + gy.astype(np.float32) ** 2 - return out - - -@njit(fastmath=True, cache=True, parallel=False) -def _laplace_square_numba(block_2d: np.ndarray) -> np.ndarray: - """ - Numba-optimized kernel for variance of Laplacian. - Calculates the Laplacian at each pixel, then returns the variance of all Laplacian values. - """ - h, w = block_2d.shape - n = h * w - - # First pass: calculate Laplacian at each pixel - laplacian_values = np.empty((h, w), dtype=np.float32) - - for i in range(h): - for j in range(w): - # Calculate Laplacian using second differences - lap = 0.0 - - # Horizontal second difference - if j > 0 and j < w - 1: - lap += block_2d[i, j + 1] - 2.0 * block_2d[i, j] + block_2d[i, j - 1] - elif j == 0: - lap += block_2d[i, j + 1] - block_2d[i, j] - else: # j == w - 1 - lap += block_2d[i, j] - block_2d[i, j - 1] - - # Vertical second difference - if i > 0 and i < h - 1: - lap += block_2d[i + 1, j] - 2.0 * block_2d[i, j] + block_2d[i - 1, j] - elif i == 0: - lap += block_2d[i + 1, j] - block_2d[i, j] - else: # i == h - 1 - lap += block_2d[i, j] - block_2d[i - 1, j] - - laplacian_values[i, j] = lap - - # Second pass: calculate mean of Laplacian values - total = 0.0 - for i in range(h): - for j in range(w): - total += laplacian_values[i, j] - mean_lap = total / n - - # Third pass: calculate variance of Laplacian values - var_sum = 0.0 - for i in range(h): - for j in range(w): - diff = laplacian_values[i, j] - mean_lap - var_sum += diff * diff - var_lap = var_sum / n - - # Clip to prevent overflow - if var_lap > 1e6: - var_lap = 1e6 - elif var_lap < 0.0: - var_lap = 0.0 - - # Fill output array with variance value - out = np.empty_like(block_2d, dtype=np.float32) - for i in range(h): - for j in range(w): - out[i, j] = var_lap - - return out - - -def _laplace_square_np(block_2d: np.ndarray) -> np.ndarray: - """ - NumPy kernel for variance of Laplacian. - """ - lap = ndi.laplace(block_2d, mode="reflect").astype(np.float32) - var_lap = np.var(lap, dtype=np.float32) - return np.full_like(block_2d, var_lap, dtype=np.float32) - - -@njit(fastmath=True, cache=True, parallel=False) -def _variance_numba(block_2d: np.ndarray) -> np.ndarray: - """ - Numba-optimized kernel for variance metric. - Returns the variance of the block as a constant array. - """ - h, w = block_2d.shape - n = h * w - - # Calculate mean - total = 0.0 - for i in range(h): - for j in range(w): - total += block_2d[i, j] - mean_val = total / n - - # Calculate variance - var_sum = 0.0 - for i in range(h): - for j in range(w): - diff = block_2d[i, j] - mean_val - var_sum += diff * diff - var_val = var_sum / n - - # Fill output array with variance value - out = np.empty_like(block_2d, dtype=np.float32) - for i in range(h): - for j in range(w): - out[i, j] = var_val - - return out - - -def _variance_np(block_2d: np.ndarray) -> np.ndarray: - """ - NumPy kernel for variance metric. - Returns the variance of the block as a constant array. - """ - var_val = np.var(block_2d, dtype=np.float32) - return np.full_like(block_2d, var_val, dtype=np.float32) - - -def _fft_high_freq_energy_np(block_2d: np.ndarray) -> np.ndarray: - """ - NumPy kernel for FFT high-frequency energy ratio. - Calculates ratio of high-frequency energy to total energy. - """ - # Normalize input to prevent overflow - block_normalized = block_2d.astype(np.float64) # Use float64 for precision - block_mean = np.mean(block_normalized) - block_std = np.std(block_normalized) - - if block_std > 0: - block_normalized = (block_normalized - block_mean) / block_std - else: - block_normalized = block_normalized - block_mean - - # Compute 2D FFT - fft_coeffs = fft2(block_normalized) - fft_magnitude = np.abs(fft_coeffs) - - # Create frequency grids - h, w = block_2d.shape - freq_y = fftfreq(h) - freq_x = fftfreq(w) - freq_grid_y, freq_grid_x = np.meshgrid(freq_y, freq_x, indexing="ij") - freq_radius = np.sqrt(freq_grid_y**2 + freq_grid_x**2) - - # Define high-frequency mask (exclude DC and very low frequencies) - # Use 10% of Nyquist frequency as threshold - high_freq_mask = freq_radius > 0.1 - - # Calculate energies with overflow protection - fft_mag_sq = fft_magnitude**2 - total_energy = np.sum(fft_mag_sq) - high_freq_energy = np.sum(fft_mag_sq[high_freq_mask]) - - # Calculate ratio (avoid division by zero and extreme values) - if total_energy > 1e-10 and np.isfinite(total_energy) and np.isfinite(high_freq_energy): - ratio = high_freq_energy / total_energy - # Clip to reasonable range to prevent extreme values - ratio = np.clip(ratio, 0.0, 1.0) - else: - ratio = 0.0 - - # Ensure finite value - if not np.isfinite(ratio): - ratio = 0.0 - - return np.full_like(block_2d, ratio, dtype=np.float32) - - -def _haar_wavelet_energy_np(block_2d: np.ndarray) -> np.ndarray: - """ - NumPy kernel for Haar wavelet detail-band energy. - Calculates energy in LH/HL/HH bands normalized by total energy. - Implements manual 2D Haar wavelet transform. - """ - try: - # Normalize input to prevent overflow - block_normalized = block_2d.astype(np.float64) # Use float64 for precision - block_mean = np.mean(block_normalized) - block_std = np.std(block_normalized) - - if block_std > 0: - block_normalized = (block_normalized - block_mean) / block_std - else: - block_normalized = block_normalized - block_mean - - # Ensure even dimensions for Haar wavelet - h, w = block_normalized.shape - data = block_normalized.copy() - - # Pad to even dimensions if needed - if h % 2 == 1: - data = np.vstack([data, data[-1:, :]]) - if w % 2 == 1: - data = np.hstack([data, data[:, -1:]]) - - # Manual 2D Haar wavelet transform - h_new, w_new = data.shape - - # Step 1: Horizontal decomposition (rows) - # Low-pass: (even + odd) / 2 - cA_h = (data[::2, :] + data[1::2, :]) / 2 # Approximation rows - # High-pass: (even - odd) / 2 - cH_h = (data[::2, :] - data[1::2, :]) / 2 # Detail rows - - # Step 2: Vertical decomposition (columns) on both subbands - # On approximation subband - cA = (cA_h[:, ::2] + cA_h[:, 1::2]) / 2 # LL (approximation) - cH = (cA_h[:, ::2] - cA_h[:, 1::2]) / 2 # LH (horizontal detail) - - # On detail subband - cV = (cH_h[:, ::2] + cH_h[:, 1::2]) / 2 # HL (vertical detail) - cD = (cH_h[:, ::2] - cH_h[:, 1::2]) / 2 # HH (diagonal detail) - - # Calculate energies with overflow protection - cA_sq = cA**2 - cH_sq = cH**2 - cV_sq = cV**2 - cD_sq = cD**2 - - total_energy = np.sum(cA_sq) + np.sum(cH_sq) + np.sum(cV_sq) + np.sum(cD_sq) - detail_energy = np.sum(cH_sq) + np.sum(cV_sq) + np.sum(cD_sq) # LH, HL, HH bands - - # Calculate normalized detail energy ratio - if total_energy > 1e-10 and np.isfinite(total_energy) and np.isfinite(detail_energy): - ratio = detail_energy / total_energy - # Clip to reasonable range to prevent extreme values - ratio = np.clip(ratio, 0.0, 1.0) - else: - ratio = 0.0 - - except Exception: - # Fallback if wavelet transform fails - ratio = 0.0 - - # Ensure finite value - if not np.isfinite(ratio): - ratio = 0.0 - - return np.full_like(block_2d, ratio, dtype=np.float32) - - -def _detect_tissue_rgb( - img_da, tile_indices: np.ndarray, tiles_y: int, tiles_x: int, ty: int, tx: int -) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """ - Internal function to detect tissue vs background using RGB values with Dask optimization. - - Parameters: - ----------- - img_da : dask.array - Image data in (y, x, c) format - tile_indices : np.ndarray - Array of shape (n_tiles, 2) with [y_idx, x_idx] for each tile - tiles_y, tiles_x : int - Grid dimensions - ty, tx : int - Tile size dimensions - - Returns: - -------- - tissue_mask : np.ndarray - Boolean array where True indicates tissue tiles - background_mask : np.ndarray - Boolean array where True indicates background tiles - tissue_similarities : np.ndarray - Similarity scores to tissue reference - background_similarities : np.ndarray - Similarity scores to background reference - """ - from dask.diagnostics import ProgressBar - from spatialdata._logging import logger as logg - - H, W, C = img_da.shape - n_tiles = len(tile_indices) - - if n_tiles > 0: - # Collect all tile mean operations as Dask arrays - tile_means = [] - for y_idx, x_idx in tile_indices: - # Calculate tile boundaries - y0 = y_idx * ty - y1 = min((y_idx + 1) * ty, H) - x0 = x_idx * tx - x1 = min((x_idx + 1) * tx, W) - - # Extract tile as Dask array and compute mean - tile = img_da[y0:y1, x0:x1] - tile_mean = tile.mean(axis=(0, 1)) - tile_means.append(tile_mean) - - stacked_means = da.stack(tile_means, axis=0) # Shape: (n_tiles, C) - - with ProgressBar(): - rgb_data = stacked_means.compute() # Single progress bar for all tiles - - else: - logg.warning("No tiles found for processing") - rgb_data = np.zeros((0, C), dtype=np.float32) - - # Identify reference tiles - # Corner tiles (background reference) - corner_positions = [ - (0, 0), # Top-left - (0, tiles_x - 1), # Top-right - (tiles_y - 1, 0), # Bottom-left - (tiles_y - 1, tiles_x - 1), # Bottom-right - ] - - # Center tiles (tissue reference) - center_y_start = int(tiles_y * 0.3) - center_y_end = int(tiles_y * 0.7) - center_x_start = int(tiles_x * 0.3) - center_x_end = int(tiles_x * 0.7) - - # Get corner tile indices - corner_indices = [] - for y_pos, x_pos in corner_positions: - corner_idx = np.where((tile_indices[:, 0] == y_pos) & (tile_indices[:, 1] == x_pos))[0] - if len(corner_idx) > 0: - corner_indices.append(corner_idx[0]) - - # Get center tile indices - center_indices = [] - for y_pos, x_pos in itertools.product(range(center_y_start, center_y_end), range(center_x_start, center_x_end)): - center_idx = np.where((tile_indices[:, 0] == y_pos) & (tile_indices[:, 1] == x_pos))[0] - if len(center_idx) > 0: - center_indices.append(center_idx[0]) - - if len(corner_indices) < 2 or len(center_indices) < 2: - logg.warning("Not enough reference tiles found, classifying all as tissue") - tissue_mask = np.ones(n_tiles, dtype=bool) - background_mask = ~tissue_mask - tissue_similarities = np.ones(n_tiles) - background_similarities = np.zeros(n_tiles) - else: - # Get reference RGB profiles - corner_rgb = rgb_data[corner_indices] # Shape: (n_corners, n_channels) - center_rgb = rgb_data[center_indices] # Shape: (n_center, n_channels) - - # Calculate mean reference profiles - background_reference = np.mean(corner_rgb, axis=0) - tissue_reference = np.mean(center_rgb, axis=0) - - # Calculate similarity to both references for all tiles using cosine similarity - with ProgressBar(): - rgb_norm = rgb_data / (np.linalg.norm(rgb_data, axis=1, keepdims=True) + 1e-8) - bg_norm = background_reference / (np.linalg.norm(background_reference) + 1e-8) - tissue_norm = tissue_reference / (np.linalg.norm(tissue_reference) + 1e-8) - - # Calculate similarities - background_similarities = np.dot(rgb_norm, bg_norm) - tissue_similarities = np.dot(rgb_norm, tissue_norm) - - # Higher similarity to corners = background, higher similarity to center = tissue - background_mask = background_similarities > tissue_similarities - tissue_mask = ~background_mask - - n_background = np.sum(background_mask) - n_tissue = np.sum(tissue_mask) - - return tissue_mask, background_mask, tissue_similarities, background_similarities - - -def detect_tissue( - sdata, image_key: str, scale: str = "scale0", tile_size: Literal["auto"] | tuple[int, int] = "auto" -) -> AnnData: - """ - Detect tissue vs background tiles using RGB values from corner (background) vs center (tissue) references. - - Parameters: - ----------- - sdata : SpatialData - Your SpatialData object. - image_key : str - Key in sdata.images. - scale : str - Multiscale level, e.g. "scale0". - tile_size : "auto" or (int, int) - Tile size dimensions. When "auto", creates roughly 100x100 grid overlay - with minimum 100x100 pixel tiles. - - Returns: - -------- - AnnData - AnnData object with tissue classification results in obs columns: - - "is_tissue": Boolean indicating tissue tiles - - "is_background": Boolean indicating background tiles - - "tissue_similarity": Similarity to tissue reference (0-1) - - "background_similarity": Similarity to background reference (0-1) - """ - from spatialdata._logging import logger as logg - - # Get image data using helper function - img_da = _get_image_data(sdata, image_key, scale) - H, W, C = img_da.shape - - logg.info("Preparing sharpness metrics calculation.") - logg.info(f"- Image size (x, y) is ({W}, {H}), channels: {C}.") - - # Calculate tile size - if tile_size == "auto": - ty, tx = _calculate_auto_tile_size(H, W) - else: - ty, tx = tile_size - if len(tile_size) == 3: - raise ValueError("tile_size must be 2D (y, x), not 3D") - - logg.info(f"Using tiles with size (x, y): ({tx}, {ty})") - - # Calculate number of tiles - tiles_y = (H + ty - 1) // ty - tiles_x = (W + tx - 1) // tx - n_tiles = tiles_y * tiles_x - - # Create tile indices and boundaries using helper function - tile_indices, obs_names, pixel_bounds = _create_tile_indices_and_bounds(tiles_y, tiles_x, ty, tx, H, W) - - # Use shared tissue detection function - tissue_mask, background_mask, tissue_similarities, background_similarities = _detect_tissue_rgb( - img_da, tile_indices, tiles_y, tiles_x, ty, tx - ) - - # Create AnnData object - adata = AnnData(X=np.zeros((n_tiles, 1))) # Dummy X matrix - adata.var_names = ["dummy"] - adata.obs_names = obs_names - - # Add tissue classification results - adata.obs["is_tissue"] = pd.Categorical(tissue_mask.astype(str), categories=["False", "True"]) - adata.obs["is_background"] = pd.Categorical(background_mask.astype(str), categories=["False", "True"]) - adata.obs["tissue_similarity"] = tissue_similarities - adata.obs["background_similarity"] = background_similarities - - # Add tile grid indices - adata.obs["tile_y"] = tile_indices[:, 0] - adata.obs["tile_x"] = tile_indices[:, 1] - - # Add tile boundaries in pixels (already calculated by helper function) - adata.obs["pixel_y0"] = pixel_bounds[:, 0] - adata.obs["pixel_x0"] = pixel_bounds[:, 1] - adata.obs["pixel_y1"] = pixel_bounds[:, 2] - adata.obs["pixel_x1"] = pixel_bounds[:, 3] - - # Add metadata - adata.uns["tissue_detection"] = { - "image_key": image_key, - "scale": scale, - "tile_size_y": ty, - "tile_size_x": tx, - "image_height": H, - "image_width": W, - "n_tiles_y": tiles_y, - "n_tiles_x": tiles_x, - "n_tissue_tiles": int(np.sum(tissue_mask)), - "n_background_tiles": int(np.sum(background_mask)), - "method": "rgb_similarity", - } - - return adata - - -def _detect_sharpness_outliers( - X: np.ndarray, method: str = "iqr", tissue_mask: np.ndarray | None = None, var_names: list | None = None -) -> np.ndarray: - """ - Detect tiles with low sharpness (blurry/out-of-focus) using parameter-free methods. - - Parameters: - ----------- - X : np.ndarray - Sharpness metrics array of shape (n_tiles, n_metrics) - method : str - Method to use: "iqr", "zscore", "tenengrad_tissue", or "pvalue" - tissue_mask : np.ndarray, optional - Boolean mask indicating tissue tiles. Required for "tenengrad_tissue" method. - - Returns: - -------- - outlier_labels : np.ndarray - Array of -1 (low sharpness outlier) or 1 (normal sharpness) labels - """ - # Clean the data to prevent infinite values - X_clean = _clean_sharpness_data(X) - - if method == "tenengrad_tissue": - return _detect_tenengrad_tissue_outliers(X_clean, tissue_mask, var_names) - elif method == "pvalue": - return _detect_outliers_pvalue(X_clean, tissue_mask, var_names) - - # Standardize the features - scaler = StandardScaler() - X_scaled = scaler.fit_transform(X_clean) - - if method == "iqr": - return _detect_outliers_iqr(X_scaled) - elif method == "zscore": - return _detect_outliers_zscore(X_scaled) - else: - raise ValueError(f"Unknown method: {method}. Use 'iqr', 'zscore', 'tenengrad_tissue', or 'pvalue'.") - - -def _clean_sharpness_data(X: np.ndarray) -> np.ndarray: - """ - Clean sharpness data by handling infinite and extreme values. - - Parameters: - ----------- - X : np.ndarray - Sharpness metrics array of shape (n_tiles, n_metrics) - - Returns: - -------- - np.ndarray - Cleaned array with finite values - """ - X_clean = X.copy() - - # Replace infinite values with NaN - X_clean[np.isinf(X_clean)] = np.nan - - # For each metric, replace NaN values with the median of that metric - for i in range(X_clean.shape[1]): - metric_data = X_clean[:, i] - if np.any(np.isnan(metric_data)): - median_val = np.nanmedian(metric_data) - X_clean[np.isnan(metric_data), i] = median_val - - # Clip extreme values to prevent overflow - # Use 99.9th percentile as upper bound - for i in range(X_clean.shape[1]): - metric_data = X_clean[:, i] - upper_bound = np.percentile(metric_data, 99.9) - lower_bound = np.percentile(metric_data, 0.1) - X_clean[:, i] = np.clip(metric_data, lower_bound, upper_bound) - - return X_clean - - -def _detect_outliers_iqr(X_scaled: np.ndarray) -> np.ndarray: - """Detect outliers using Interquartile Range (IQR) method - only low sharpness.""" - # Calculate IQR for each metric - Q1 = np.percentile(X_scaled, 25, axis=0) - Q3 = np.percentile(X_scaled, 75, axis=0) - IQR = Q3 - Q1 - - # Define outlier bounds (1.5 * IQR rule) - only lower bound for low sharpness - lower_bound = Q1 - 1.5 * IQR - - # Flag outliers only if sharpness is LOW (below lower bound) - # High sharpness is good, so we don't flag upper outliers - outlier_mask = np.any(X_scaled < lower_bound, axis=1) - - # Convert to -1/1 format - outlier_labels = np.where(outlier_mask, -1, 1) - - return outlier_labels - - -def _detect_outliers_zscore(X_scaled: np.ndarray, threshold: float = 3.0) -> np.ndarray: - """Detect outliers using Z-score method - only low sharpness.""" - # Calculate Z-scores (X_scaled is already standardized) - # Only check for LOW sharpness (negative z-scores) - z_scores = X_scaled # X_scaled is already standardized - - # Flag tiles that have LOW sharpness (negative z-scores below threshold) - # High sharpness (positive z-scores) is good, so we don't flag those - outlier_mask = np.any(z_scores < -threshold, axis=1) - - # Convert to -1/1 format - outlier_labels = np.where(outlier_mask, -1, 1) - - return outlier_labels - - -def _detect_tenengrad_tissue_outliers( - X: np.ndarray, tissue_mask: np.ndarray | None = None, var_names: list | None = None -) -> np.ndarray: - """ - Calculate unfocus likelihood scores for tissue tiles using Tenengrad scores with background reference. - - Logic: - 1. Use background tiles as reference for "blurry" appearance - 2. Compare tissue tiles to both background and other tissue tiles - 3. Calculate continuous scores (0-1) where higher values indicate more likely to be unfocused - 4. Scores are normalized: 0 = very sharp, 1 = very blurry (background-like) - - Parameters - ---------- - X : np.ndarray - Sharpness scores array of shape (n_tiles, n_metrics) - tissue_mask : np.ndarray, optional - Boolean mask indicating tissue tiles - var_names : list, optional - Variable names for the metrics - - Returns - ------- - np.ndarray - Array of unfocus likelihood scores (0-1) for all tiles - """ - from sklearn.mixture import GaussianMixture - - if tissue_mask is None: - # If no tissue mask, fall back to standard zscore method - return _detect_outliers_zscore(X) - - # Get background and tissue tiles - background_mask = ~tissue_mask - tissue_tiles = X[tissue_mask] - background_tiles = X[background_mask] - - if len(tissue_tiles) == 0 or len(background_tiles) == 0: - return np.zeros(len(X)) # No unfocus if no tissue or background - - # Find Tenengrad metric index - tenengrad_idx = None - for i, var_name in enumerate(var_names): - if "tenengrad" in var_name.lower(): - tenengrad_idx = i - break - - if tenengrad_idx is None: - # Fallback to standard zscore method if Tenengrad not found - return _detect_outliers_zscore(X) - - # Extract Tenengrad scores using the correct index - tissue_tenengrad = tissue_tiles[:, tenengrad_idx] - background_tenengrad = background_tiles[:, tenengrad_idx] - - # Step 1: Analyze background distribution - background_mean = np.mean(background_tenengrad) - background_std = np.std(background_tenengrad) - - # Step 2: Analyze tissue distribution - tissue_mean = np.mean(tissue_tenengrad) - tissue_std = np.std(tissue_tenengrad) - - # Step 3: Determine if tissue has one or two classes - # Use Gaussian Mixture Model to test for bimodality - if len(tissue_tenengrad) >= 4: # Need at least 4 samples for GMM - # Try 1-component and 2-component models - gmm_1 = GaussianMixture(n_components=1, random_state=42) - gmm_2 = GaussianMixture(n_components=2, random_state=42) - - try: - gmm_1.fit(tissue_tenengrad.reshape(-1, 1)) - gmm_2.fit(tissue_tenengrad.reshape(-1, 1)) - - # Use BIC to determine best model - bic_1 = gmm_1.bic(tissue_tenengrad.reshape(-1, 1)) - bic_2 = gmm_2.bic(tissue_tenengrad.reshape(-1, 1)) - - # If 2-component model is significantly better, use it - bic_improvement = bic_1 - bic_2 - use_two_components = bic_improvement > 10 # Threshold for significant improvement - - except: - # If GMM fails, use simple threshold method - use_two_components = False - else: - use_two_components = False - - # Step 4: Calculate continuous unfocus likelihood scores - if use_two_components: - # Two-class case: use GMM probabilities - gmm_2.fit(tissue_tenengrad.reshape(-1, 1)) - tissue_probs = gmm_2.predict_proba(tissue_tenengrad.reshape(-1, 1)) - - # Determine which component is "blurry" (closer to background) - component_means = gmm_2.means_.flatten() - blurry_component = np.argmin(np.abs(component_means - background_mean)) - - # Use probability of belonging to blurry component as unfocus score - unfocus_scores = tissue_probs[:, blurry_component] - - else: - # One-class case: calculate distance-based scores - # Normalize tissue scores relative to background distribution - # Score = 1 - (tissue_score - background_min) / (background_max - background_min) - background_min = np.min(background_tenengrad) - background_max = np.max(background_tenengrad) - background_range = background_max - background_min - - if background_range > 0: - # Normalize tissue scores to [0, 1] where 0 = sharp, 1 = background-like - normalized_scores = (tissue_tenengrad - background_min) / background_range - # Invert so higher Tenengrad = lower unfocus score - unfocus_scores = 1.0 - np.clip(normalized_scores, 0, 1) - else: - # If background has no variation, use simple threshold - tissue_background_ratio = tissue_mean / (background_mean + 1e-10) - if tissue_background_ratio > 1.5: - # Tissue is sharp - score based on distance from tissue mean - unfocus_scores = np.clip((tissue_mean - tissue_tenengrad) / (tissue_std + 1e-10), 0, 1) - else: - # Tissue is blurry - score based on similarity to background - unfocus_scores = np.clip((tissue_tenengrad - background_mean) / (background_std + 1e-10), 0, 1) - - # Step 5: Create full unfocus scores array - full_scores = np.zeros(len(X)) # Start with all sharp (0) - tissue_indices = np.where(tissue_mask)[0] - full_scores[tissue_indices] = unfocus_scores # Set tissue unfocus scores - - return full_scores - - -def _detect_outliers_pvalue( - X: np.ndarray, tissue_mask: np.ndarray | None = None, var_names: list | None = None, alpha: float = 0.05 -) -> tuple[np.ndarray, np.ndarray]: - """ - Detect outliers using p-value method based on background distribution. - - For each tile, calculates the p-value of observing its sharpness score - given the distribution of background tiles. Tiles with p-values below alpha - are considered outliers (unlikely to come from background distribution). - - Parameters: - ----------- - X : np.ndarray - Sharpness metrics array of shape (n_tiles, n_metrics) - tissue_mask : np.ndarray, optional - Boolean mask indicating tissue tiles. If None, uses all tiles. - var_names : list, optional - List of metric names. If None, uses all metrics. - alpha : float - Significance level for outlier detection (default: 0.05) - - Returns: - -------- - outlier_labels : np.ndarray - Array of -1 (outlier) or 1 (normal) labels - p_values : np.ndarray - Array of p-values for each tile - """ - from scipy import stats - - if tissue_mask is None: - # If no tissue mask, use all tiles - tissue_mask = np.ones(len(X), dtype=bool) - - if var_names is None: - var_names = [f"metric_{i}" for i in range(X.shape[1])] - - # Separate tissue and background tiles - tissue_tiles = X[tissue_mask] - background_tiles = X[~tissue_mask] - - if len(background_tiles) < 10: - # Not enough background tiles for reliable statistics - return np.ones(len(X), dtype=int), np.ones(len(X)) - - # Calculate p-values for each metric - p_values = np.ones((len(tissue_tiles), len(var_names))) - - for i, var_name in enumerate(var_names): - tissue_scores = tissue_tiles[:, i] - background_scores = background_tiles[:, i] - - # Fit normal distribution to background scores - bg_mean = np.mean(background_scores) - bg_std = np.std(background_scores) - - if bg_std < 1e-10: - # No variation in background, skip this metric - continue - - # Calculate p-values for tissue scores - # For sharpness metrics, we typically want to detect LOW values (blurry tiles) - # So we calculate the probability of observing a value as low or lower - p_values[:, i] = stats.norm.cdf(tissue_scores, loc=bg_mean, scale=bg_std) - - # Combine p-values across metrics using Fisher's method or minimum p-value - # Using minimum p-value approach (more conservative) - min_p_values = np.min(p_values, axis=1) - - # Create full p-values array - full_p_values = np.ones(len(X)) - tissue_indices = np.where(tissue_mask)[0] - full_p_values[tissue_indices] = min_p_values - - # Flag outliers: p-value < alpha (unlikely to come from background) - outlier_mask = full_p_values < alpha - - # Convert to -1/1 format - outlier_labels = np.where(outlier_mask, -1, 1) - - return outlier_labels, full_p_values - - -def _calculate_auto_tile_size(height: int, width: int) -> tuple[int, int]: - """ - Calculate tile size for auto mode: roughly 100x100 grid overlay with 100px minimum. - Creates square tiles that evenly divide the image dimensions. - - Parameters - ---------- - height : int - Image height in pixels - width : int - Image width in pixels - - Returns - ------- - Tuple[int, int] - Tile size as (y, x) in pixels (always square) - """ - # Calculate how many tiles we want (roughly 100 in the larger dimension) - target_tiles = 100 - - # Calculate tile size for each dimension - tile_size_y = height // target_tiles - tile_size_x = width // target_tiles - - # Use the smaller tile size to ensure both dimensions are covered - tile_size = min(tile_size_y, tile_size_x) - - # Ensure minimum 100x100 pixel tiles - return (100, 100) if tile_size < 100 else (tile_size, tile_size) - - -def _make_tiles( - arr_yx: da.Array, - tile_size: Literal["auto"] | tuple[int, int], -) -> tuple[int, int]: - """ - Decide tile size based on tile_size parameter. - - Parameters - ---------- - arr_yx : da.Array - Dask array with (y, x) dimensions - tile_size : "auto" or (int, int) - Tile size dimensions. When "auto", creates roughly 100x100 grid overlay. - - Returns - ------- - Tuple[int, int] - Tile size as (y, x) in pixels - """ - - if tile_size == "auto": - return _calculate_auto_tile_size(arr_yx.shape[0], arr_yx.shape[1]) - - if isinstance(tile_size, tuple): - if len(tile_size) == 2: - return int(tile_size[0]), int(tile_size[1]) - else: - raise ValueError(f"tile_size tuple must have exactly 2 dimensions (y, x), got {len(tile_size)}") - - raise ValueError(f"tile_size must be 'auto' or a 2-tuple, got {type(tile_size)}") - - -def _create_tile_indices_and_bounds( - tiles_y: int, tiles_x: int, tile_size_y: int, tile_size_x: int, image_height: int, image_width: int -) -> tuple[np.ndarray, list, np.ndarray]: - """ - Create tile indices, observation names, and pixel boundaries. - - Parameters - ---------- - tiles_y, tiles_x : int - Grid dimensions - tile_size_y, tile_size_x : int - Tile size dimensions - image_height, image_width : int - Image dimensions - - Returns - ------- - Tuple[np.ndarray, list, np.ndarray] - Tuple containing: - - tile_indices: Array of shape (n_tiles, 2) with [y_idx, x_idx] - - obs_names: List of observation names - - pixel_bounds: Array of shape (n_tiles, 4) with [y0, x0, y1, x1] - """ - tile_indices = [] - obs_names = [] - pixel_bounds = [] - - for y_idx in range(tiles_y): - for x_idx in range(tiles_x): - tile_indices.append([y_idx, x_idx]) - obs_names.append(f"tile_x{x_idx}_y{y_idx}") - - # Calculate tile boundaries ensuring complete coverage - y0 = y_idx * tile_size_y - x0 = x_idx * tile_size_x - y1 = (y_idx + 1) * tile_size_y if y_idx < tiles_y - 1 else image_height - x1 = (x_idx + 1) * tile_size_x if x_idx < tiles_x - 1 else image_width - pixel_bounds.append([y0, x0, y1, x1]) - - return np.array(tile_indices), obs_names, np.array(pixel_bounds) - - -def _create_tile_polygons( - scores: np.ndarray, tile_size_y: int, tile_size_x: int, image_height: int, image_width: int -) -> tuple[np.ndarray, list]: - """ - Create rectangular polygons for each tile in the grid. - - Parameters - ---------- - scores : np.ndarray - 2D array of tile scores with shape (tiles_y, tiles_x) - tile_size_y : int - Height of each tile in pixels - tile_size_x : int - Width of each tile in pixels - image_height : int - Total image height in pixels - image_width : int - Total image width in pixels - - Returns - ------- - Tuple[np.ndarray, list] - Tuple containing: - - 2D array of tile centroids (y, x coordinates) - - List of Polygon objects for each tile - """ - tiles_y, tiles_x = scores.shape - centroids = [] - polygons = [] - - for y_idx in range(tiles_y): - for x_idx in range(tiles_x): - # Calculate tile boundaries in pixel coordinates - # Ensure complete coverage by extending last tiles to image boundaries - y0 = y_idx * tile_size_y - x0 = x_idx * tile_size_x - y1 = (y_idx + 1) * tile_size_y if y_idx < tiles_y - 1 else image_height - x1 = (x_idx + 1) * tile_size_x if x_idx < tiles_x - 1 else image_width - - # Calculate centroid - centroid_y = (y0 + y1) / 2 - centroid_x = (x0 + x1) / 2 - centroids.append([centroid_y, centroid_x]) - - # Create rectangular polygon for this tile - # Note: Polygon expects (x, y) coordinates, not (y, x) - polygon = Polygon( - [ - (x0, y0), # bottom-left - (x1, y0), # bottom-right - (x1, y1), # top-right - (x0, y1), # top-left - (x0, y0), # close polygon - ] - ) - polygons.append(polygon) - - return np.array(centroids), polygons - - -class SHARPNESS_METRICS(Enum): - TENENGRAD = enum.auto() - VAR_OF_LAPLACIAN = enum.auto() - VARIANCE = enum.auto() - FFT_HIGH_FREQ_ENERGY = enum.auto() - HAAR_WAVELET_ENERGY = enum.auto() - - -def qc_sharpness( - sdata, - image_key: str, - scale: str = "scale0", - metrics: SHARPNESS_METRICS | list[SHARPNESS_METRICS] = [ - SHARPNESS_METRICS.TENENGRAD, - SHARPNESS_METRICS.VAR_OF_LAPLACIAN, - ], - tile_size: Literal["auto"] | tuple[int, int] = "auto", - detect_outliers: bool = True, - detect_tissue: bool = True, - outlier_method: str = "pvalue", - outlier_cutoff: float = 0.1, - progress: bool = True, -) -> None: - """ - Compute tilewise sharpness scores for multiple metrics and print the values. - - Parameters - ---------- - sdata : SpatialData - Your SpatialData object. - image_key : str - Key in sdata.images. - scale : str - Multiscale level, e.g. "scale0". - metrics : SHARPNESS_METRICS or "all" - Sharpness metrics to compute. If "all", computes all available metrics. - tile_size : "auto" or (int, int) - Tile size dimensions. When "auto", creates roughly 100x100 grid overlay - with minimum 100x100 pixel tiles. - detect_outliers : bool - If True, identify tiles with abnormal sharpness scores. If `detect_tissue=True`, - outlier detection will only run on tissue tiles. Adds `sharpness_outlier` column - to the AnnData table. - detect_tissue : bool - Only evaluated if `detect_outliers=True`. If True, classify tiles as background - vs tissue using RGB values from corner (background) vs center (tissue) - references. Adds `is_tissue`, `is_background`, `tissue_similarity`, and - `background_similarity` columns to the AnnData table. - outlier_method : str - Method for detecting low sharpness tiles: "iqr", "zscore", "tenengrad_tissue", or "pvalue". - - "iqr": Interquartile Range method (parameter-free) - - "zscore": Z-score method (parameter-free) - - "tenengrad_tissue": Tenengrad-based method using background reference (requires detect_tissue=True) - - "pvalue": P-value method based on background distribution (requires detect_tissue=True, falls back to zscore if not available) - Default "pvalue" uses P-value-based method. Only flags tiles with LOW sharpness. - outlier_cutoff : float - Threshold for binarizing continuous unfocus scores into outlier labels. - Tiles with unfocus_score >= outlier_cutoff are marked as outliers. - progress : bool - Show a Dask progress bar for the compute step. - - Returns - ------- - None - Prints results to stdout and adds TableModel and ShapesModel to sdata. - Table key: "qc_img_{image_key}_sharpness" - Shapes key: "qc_img_{image_key}_sharpness_grid" - When `detect_outliers=True`, adds "sharpness_outlier" column to AnnData.obs. - When `detect_tissue=True` and `detect_outliers=True`, also adds "is_tissue", - "is_background", "tissue_similarity", and "background_similarity" columns. - "sharpness_outlier" flags tiles with low sharpness (blurry/out-of-focus). - """ - from spatialdata import SpatialData - - # 1) Get image data using helper function - img_da = _get_image_data(sdata, image_key, scale) - - # 2) Ensure dims and grayscale - img_yxc = _ensure_yxc(img_da) - gray = _to_gray_dask_yx(img_yxc) # (y, x), float32 dask array - H, W = gray.shape - - # 3) Determine which metrics to compute - - # 4) Calculate tile size - ty, tx = _make_tiles(gray, tile_size) - logg.info("Preparing sharpness metrics calculation.") - logg.info(f"- Image size (x, y) is ({W}, {H}), using tiles with size (x, y): ({tx}, {ty}).") - - # Calculate tile grid dimensions - tiles_y = (H + ty - 1) // ty - tiles_x = (W + tx - 1) // tx - n_tiles = tiles_y * tiles_x - logg.info(f"- Resulting tile grid has shape (x, y): ({tiles_x}, {tiles_y}).") - - # 5) Compute sharpness scores for all metrics - all_scores = {} - - print("") - logg.info("Calculating sharpness metrics.") - metrics_to_compute = metrics if isinstance(metrics, list) else [metrics] - for metric in metrics_to_compute: - metric_name = metric.name.lower() if isinstance(metric, SHARPNESS_METRICS) else metric - logg.info(f"- Computing sharpness metric '{metric_name}'.") - - # Force Dask to use smaller chunks that match tile size - # This prevents large chunks from creating uniform blocks - gray_rechunked = gray.rechunk((ty, tx)) - - # Per-pixel sharpness via map_overlap (no overlap for adjacent tiles) - if metric_name == "tenengrad": - sharp_field = da.map_overlap( - _sobel_energy_numba, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 - ) - elif metric_name == "var_of_laplacian": - sharp_field = da.map_overlap( - _laplace_square_numba, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 - ) - elif metric_name == "variance": - sharp_field = da.map_overlap(_variance_numba, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32) - elif metric_name == "fft_high_freq_energy": - sharp_field = da.map_overlap( - _fft_high_freq_energy_np, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 - ) - elif metric_name == "haar_wavelet_energy": - sharp_field = da.map_overlap( - _haar_wavelet_energy_np, gray_rechunked, depth=0, boundary="reflect", dtype=np.float32 - ) - else: - raise ValueError(f"- Unknown metric '{metric_name}'.") - - # Pad the array to make it divisible by tile size - pad_y = (tiles_y * ty) - sharp_field.shape[0] - pad_x = (tiles_x * tx) - sharp_field.shape[1] - - if pad_y > 0 or pad_x > 0: - # Pad with edge values - padded = da.pad(sharp_field, ((0, pad_y), (0, pad_x)), mode="edge") - else: - padded = sharp_field - - # Now coarsen with trim_excess=False since dimensions are divisible - # For additive metrics, we need to normalize by tile area to make them tile-size independent - if metric_name in ["tenengrad"]: - # Sum across tile, then normalize by tile area - tile_scores = da.coarsen(np.sum, padded, {0: ty, 1: tx}, trim_excess=False) - tile_area = ty * tx - tile_scores = tile_scores / tile_area - else: - # For other metrics (variance, entropy, fft, haar), use mean (already normalized) - tile_scores = da.coarsen(np.mean, padded, {0: ty, 1: tx}, trim_excess=False) - - # Compute scores with progress bar if requested - if progress: - with ProgressBar(): - scores = tile_scores.compute() # 2D numpy array - else: - scores = tile_scores.compute() - - all_scores[metric_name] = scores - - # Get dimensions from first metric - first_metric = list(all_scores.keys())[0] - scores = all_scores[first_metric] - tiles_y, tiles_x = scores.shape - - # Use original tile dimensions since padding ensures divisibility - actual_ty = ty - actual_tx = tx - - # Generate keys based on naming convention - table_key = f"qc_img_{image_key}_sharpness" - shapes_key = f"qc_img_{image_key}_sharpness_grid" - - # Create tile polygons and centroids using actual tile dimensions - centroids, polygons = _create_tile_polygons(scores, actual_ty, actual_tx, H, W) - - # Create AnnData object with sharpness scores as variables (genes) - n_tiles = len(centroids) - n_metrics = len(all_scores) - - # Create X matrix with sharpness scores (tiles x metrics) - X_data = np.zeros((n_tiles, n_metrics)) - var_names = [] - for i, (metric_name, metric_scores) in enumerate(all_scores.items()): - X_data[:, i] = metric_scores.ravel() - var_names.append(f"sharpness_{metric_name}") - - # Create tile indices and boundaries using helper function - tile_indices, obs_names, pixel_bounds = _create_tile_indices_and_bounds( - tiles_y, tiles_x, actual_ty, actual_tx, H, W - ) - - # Initialize default values - outlier_labels = np.ones(len(X_data), dtype=int) # All normal by default - - adata = AnnData(X=X_data) - adata.var_names = var_names - adata.obs_names = obs_names - adata.obs["centroid_y"] = centroids[:, 0] - adata.obs["centroid_x"] = centroids[:, 1] - adata.obsm["spatial"] = centroids - - # Perform outlier detection if requested - if detect_outliers: - print("") - logg.info(f"Detecting outlier tiles using method '{outlier_method}'...") - - if detect_tissue: - logg.info("- Classifying tiles as tissue vs background.") - tissue_mask = np.ones(len(X_data), dtype=bool) - background_mask = np.zeros(len(X_data), dtype=bool) - tissue_similarities = np.zeros(len(X_data), dtype=np.float32) - background_similarities = np.zeros(len(X_data), dtype=np.float32) - - # Use the already loaded and processed image data - img_da = img_yxc # Already in (y, x, c) format from line 704 - - # Use shared tissue detection function - tissue_mask, background_mask, tissue_similarities, background_similarities = _detect_tissue_rgb( - img_da, tile_indices, tiles_y, tiles_x, actual_ty, actual_tx - ) - n_background = np.sum(background_mask) - n_tissue = np.sum(tissue_mask) - - logg.info(f"- Classified {n_background} tiles as background and {n_tissue} tiles as tissue.") - - # Outlier detection - if detect_tissue and np.sum(tissue_mask) > 0: - logg.info("- Running outlier detection on tissue tiles only.") - if outlier_method == "pvalue": - # Use p-value method with tissue mask - outlier_labels, pvalues = _detect_outliers_pvalue(X_data, tissue_mask=tissue_mask, var_names=var_names) - # Convert p-values to unfocus scores (0 = focused, 1 = unfocused) - # Lower p-values (more unlikely to be background) = higher unfocus scores - unfocus_scores = 1.0 - pvalues - min_score = np.min(unfocus_scores) - max_score = np.max(unfocus_scores) - if max_score > min_score: # Avoid division by zero - unfocus_scores = (unfocus_scores - min_score) / (max_score - min_score) - else: - unfocus_scores = np.zeros_like(unfocus_scores) - # Use outlier_cutoff to binarize unfocus scores - outlier_labels = np.where(unfocus_scores >= outlier_cutoff, -1, 1) - elif outlier_method == "tenengrad_tissue": - # Use the new Tenengrad-based method with tissue mask (returns continuous scores) - unfocus_scores = _detect_tenengrad_tissue_outliers(X_data, tissue_mask=tissue_mask, var_names=var_names) - # Use outlier_cutoff to binarize unfocus scores - outlier_labels = np.where(unfocus_scores >= outlier_cutoff, -1, 1) - else: - # Use standard method on tissue tiles only - tissue_data = X_data[tissue_mask] - unfocus_scores = _detect_sharpness_outliers(tissue_data, method=outlier_method) - - # Map back to all tiles - outlier_labels[tissue_mask] = unfocus_scores - - n_outliers = np.sum(outlier_labels == -1) - logg.info(f"- Classified {n_outliers} tiles as outliers ({n_outliers / np.sum(tissue_mask) * 100:.1f}%).") - else: - logg.info("- Running outlier detection on all tiles.") - if outlier_method == "pvalue": - # P-value method requires tissue detection, fall back to zscore - logg.warning("P-value method requires tissue detection. Falling back to zscore method.") - outlier_labels = _detect_sharpness_outliers(X_data, method="zscore", var_names=var_names) - unfocus_scores = None - else: - outlier_labels = _detect_sharpness_outliers(X_data, method=outlier_method, var_names=var_names) - - n_outliers = np.sum(outlier_labels == -1) - logg.info(f"- Classified {n_outliers} tiles as outliers ({n_outliers / len(outlier_labels) * 100:.1f}%).") - unfocus_scores = None # No continuous scores for standard methods - - if detect_tissue: - adata.obs["is_tissue"] = pd.Categorical(tissue_mask.astype(str), categories=["False", "True"]) - adata.obs["is_background"] = pd.Categorical(background_mask.astype(str), categories=["False", "True"]) - - adata.obs["tissue_similarity"] = tissue_similarities - adata.obs["background_similarity"] = background_similarities - adata.obs["sharpness_outlier"] = pd.Categorical( - (outlier_labels == -1).astype(str), categories=["False", "True"] - ) - - # Add continuous unfocus scores if available - if unfocus_scores is not None: - adata.obs["unfocus_score"] = unfocus_scores - - # Add metadata - adata.uns["qc_sharpness"] = { - "metrics": list(all_scores.keys()), - "tile_size_y": actual_ty, - "tile_size_x": actual_tx, - "image_height": H, - "image_width": W, - "n_tiles_y": tiles_y, - "n_tiles_x": tiles_x, - "image_key": image_key, - "scale": scale, - "detect_tissue": detect_tissue, - "outlier_method": outlier_method, - "n_tissue_tiles": int(np.sum(tissue_mask)), - "n_background_tiles": int(np.sum(background_mask)), - "n_outlier_tiles": int(np.sum(outlier_labels == -1)), - } - - # Add results to SpatialData - table_model = TableModel.parse(adata) - sdata.tables[table_key] = table_model - - logg.info(f"- Stored tiles as sdata.tables['{table_key}'].") - - # Create GeoDataFrame with tile polygons (ensuring complete coverage) - tile_data = [] - for y_idx, x_idx in tile_indices: - y0 = y_idx * actual_ty - x0 = x_idx * actual_tx - y1 = (y_idx + 1) * actual_ty if y_idx < tiles_y - 1 else H - x1 = (x_idx + 1) * actual_tx if x_idx < tiles_x - 1 else W - - # Create rectangular polygon for this tile - polygon = Polygon( - [ - (x0, y0), # bottom-left - (x1, y0), # bottom-right - (x1, y1), # top-right - (x0, y1), # top-left - (x0, y0), # close polygon - ] - ) - - # Create tile data (without metrics - they're only in the table) - tile_info = { - "tile_id": f"tile_x{x_idx}_y{y_idx}", - "tile_y": y_idx, - "tile_x": x_idx, - "pixel_y0": y0, - "pixel_x0": x0, - "pixel_y1": y1, - "pixel_x1": x1, - "geometry": polygon, - } - - tile_data.append(tile_info) - - tile_gdf = gpd.GeoDataFrame(tile_data, geometry="geometry") - shapes_model = ShapesModel.parse(tile_gdf) - sdata.shapes[shapes_key] = shapes_model - - sdata.tables[table_key].uns["spatialdata_attrs"] = { - "region": shapes_key, - "region_key": "grid_name", - "instance_key": "tile_id", - } - sdata.tables[table_key].obs["grid_name"] = pd.Categorical([shapes_key] * len(sdata.tables[table_key])) - sdata.tables[table_key].obs["tile_id"] = shapes_model.index - - logg.info(f"- Stored sharpness metrics as sdata.shapes['{shapes_key}'].") diff --git a/src/squidpy/exp/pl/__init__.py b/src/squidpy/exp/pl/__init__.py deleted file mode 100644 index 34781f236..000000000 --- a/src/squidpy/exp/pl/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from ._qc import qc_sharpness_metrics - -__all__ = ["qc_sharpness_metrics"] diff --git a/src/squidpy/experimental/__init__.py b/src/squidpy/experimental/__init__.py index df8681a40..435cd0098 100644 --- a/src/squidpy/experimental/__init__.py +++ b/src/squidpy/experimental/__init__.py @@ -6,7 +6,6 @@ from __future__ import annotations -from . import im -from .im._detect_tissue import detect_tissue +from . import im, pl -__all__ = ["detect_tissue", "im"] +__all__ = ["im", "pl"] diff --git a/src/squidpy/experimental/im/__init__.py b/src/squidpy/experimental/im/__init__.py index 5c43a7b78..d5296a611 100644 --- a/src/squidpy/experimental/im/__init__.py +++ b/src/squidpy/experimental/im/__init__.py @@ -5,5 +5,13 @@ FelzenszwalbParams, detect_tissue, ) +from ._qc_sharpness import qc_sharpness +from ._sharpness_metrics import SharpnessMetric -__all__ = ["detect_tissue", "BackgroundDetectionParams", "FelzenszwalbParams"] +__all__ = [ + "qc_sharpness", + "detect_tissue", + "SharpnessMetric", + "BackgroundDetectionParams", + "FelzenszwalbParams", +] diff --git a/src/squidpy/experimental/im/_detect_tissue.py b/src/squidpy/experimental/im/_detect_tissue.py index 3b0fa8a78..64599c344 100644 --- a/src/squidpy/experimental/im/_detect_tissue.py +++ b/src/squidpy/experimental/im/_detect_tissue.py @@ -17,9 +17,9 @@ from spatialdata.models import Labels2DModel from spatialdata.transformations import get_transformation -from squidpy._utils import _get_scale_factors, _yx_from_shape +from squidpy._utils import _ensure_dim_order, _get_scale_factors, _yx_from_shape -from ._utils import _flatten_channels, _get_image_data +from ._utils import _flatten_channels, _get_element_data class DETECT_TISSUE_METHOD(enum.Enum): @@ -170,7 +170,9 @@ def detect_tissue( manual_scale = scale.lower() != "auto" # Load smallest available or explicit scale - img_src = _get_image_data(sdata, image_key, scale=scale if manual_scale else "auto") + img_node = sdata.images[image_key] + img_da = _get_element_data(img_node, scale if manual_scale else "auto", "image", image_key) + img_src = _ensure_dim_order(img_da, "yxc") src_h, src_w = _yx_from_shape(img_src.shape) n_src_px = src_h * src_w diff --git a/src/squidpy/experimental/im/_qc_sharpness.py b/src/squidpy/experimental/im/_qc_sharpness.py new file mode 100644 index 000000000..d1277eb68 --- /dev/null +++ b/src/squidpy/experimental/im/_qc_sharpness.py @@ -0,0 +1,599 @@ +from __future__ import annotations + +from typing import Literal + +import dask.array as da +import geopandas as gpd +import numba +import numpy as np +import pandas as pd +import xarray as xr +from anndata import AnnData +from dask.diagnostics import ProgressBar +from sklearn.preprocessing import StandardScaler +from spatialdata import SpatialData +from spatialdata._logging import logger +from spatialdata.models import ShapesModel, TableModel + +from squidpy._docs import d +from squidpy._utils import _ensure_dim_order + +from ._detect_tissue import detect_tissue +from ._sharpness_metrics import SharpnessMetric, _get_sharpness_metric_function +from ._utils import TileGrid, _get_element_data + +# single-thread numba to avoid clashes with Dask +numba.set_num_threads(1) + + +@d.dedent +def qc_sharpness( + sdata: SpatialData, + image_key: str, + *, + scale: str = "scale0", + metrics: SharpnessMetric | list[SharpnessMetric] | None = None, + tile_size: Literal["auto"] | tuple[int, int] = "auto", + detect_outliers: bool = True, + detect_tissue: bool = True, + outlier_method: Literal["pvalue", "iqr", "zscore", "tenengrad_tissue"] = "pvalue", + outlier_cutoff: float = 0.1, + progress: bool = True, + tissue_mask_key: str | None = None, +) -> None: + """ + Perform quality control analysis on image sharpness. + + Parameters + ---------- + sdata + SpatialData object containing the image. + image_key + Key of the image in ``sdata.images`` to analyze. + scale + Scale level to use for processing. Defaults to ``"scale0"``. + metrics + Sharpness metrics to compute. Can be a single metric or list of metrics. + tile_size + Size of tiles for analysis. If ``"auto"``, automatically determines size. + detect_outliers + Whether to detect outlier tiles based on sharpness scores. + detect_tissue + Whether to detect tissue regions for context-aware outlier detection. + outlier_method + Method for outlier detection. Options: ``"pvalue"``, ``"iqr"``, ``"zscore"``, ``"tenengrad_tissue"``. + outlier_cutoff + Threshold for outlier detection. + progress + Whether to show progress bar during computation. + tissue_mask_key + Key of the tissue mask in ``sdata.labels`` to use. If ``None``, the function will + check if ``"{image_key}_tissue"`` already exists in ``sdata.labels`` and reuse it. + If it doesn't exist, tissue detection will be performed and the mask will be added + to ``sdata.labels`` with key ``"{image_key}_tissue"``. If provided, the existing + mask at this key will be used. + + Returns + ------- + None + Results are stored in the following locations: + + - ``sdata.tables[f"qc_img_{image_key}_sharpness"]``: AnnData object with sharpness scores + - ``sdata.shapes[f"qc_img_{image_key}_sharpness_grid"]``: GeoDataFrame with tile geometries + - ``sdata.tables[...].uns["qc_sharpness"]``: Metadata about the analysis + + Notes + ----- + This function performs tile-based sharpness analysis on images, computing + various sharpness metrics and optionally detecting outlier tiles. + """ + # Parameter validation + if image_key not in sdata.images: + raise KeyError(f"Image key '{image_key}' not found in sdata.images") + + if metrics is None: + metrics = [SharpnessMetric.TENENGRAD, SharpnessMetric.VAR_OF_LAPLACIAN] + elif isinstance(metrics, SharpnessMetric): + metrics = [metrics] + + if not isinstance(metrics, list) or not all(isinstance(m, SharpnessMetric) for m in metrics): + raise TypeError("metrics must be SharpnessMetric or list of SharpnessMetric") + + if isinstance(metrics, list) and not all(isinstance(m, SharpnessMetric) for m in metrics): + available = ", ".join(m.value for m in SharpnessMetric) + raise TypeError(f"Metrics must be one of: {available}") + + if outlier_method not in ["pvalue", "iqr", "zscore", "tenengrad_tissue"]: + raise ValueError( + f"Unknown outlier_method '{outlier_method}'. Must be one of: pvalue, iqr, zscore, tenengrad_tissue" + ) + + # Compute sharpness metrics + img_node = sdata.images[image_key] + img_da = _get_element_data(img_node, scale, "image", image_key) + img_yxc = _ensure_dim_order(img_da, "yxc") + gray = _to_gray_dask_yx(img_yxc) + H, W = int(gray.shape[0]), int(gray.shape[1]) + + tg = TileGrid(H, W, tile_size) + tile_indices = tg.indices() + obs_names = tg.names() + pixel_bounds = tg.bounds() + + logger.info("Quantifying image sharpness.") + logger.info(f"- Input image (x, y): ({W}, {H})") + logger.info(f"- Tile size (x, y): ({tg.tx}, {tg.ty})") + logger.info(f"- Number of tiles (n_x, n_y): ({tg.tiles_x}, {tg.tiles_y})") + + metrics_list = metrics if isinstance(metrics, list) else [metrics] + metric_names = [(m.value if isinstance(m, SharpnessMetric) else str(m)) for m in metrics_list] + + all_scores: dict[str, np.ndarray] = {} + for name in metric_names: + gray_re = gray.rechunk((tg.ty, tg.tx)) + metric_func = _get_sharpness_metric_function(name) + field = da.map_overlap(metric_func, gray_re, depth=0, boundary="reflect", dtype=np.float32) + + padded = tg.rechunk_and_pad(field) + + if name == "tenengrad": + tiles_da = tg.coarsen(padded, "sum") / float(tg.ty * tg.tx) + else: + tiles_da = tg.coarsen(padded, "mean") + + logger.info(f"- Calculating metric: '{name}'") + if progress: + with ProgressBar(): + all_scores[name] = tiles_da.compute() + else: + all_scores[name] = tiles_da.compute() + + # build AnnData + first = next(iter(all_scores.values())) + cents, polys = tg.centroids_and_polygons() + n_tiles = first.size + X = np.column_stack([all_scores[n].ravel() for n in metric_names]) + var_names = [f"sharpness_{n}" for n in metric_names] + + adata = AnnData(X=X) + adata.var_names = var_names + adata.obs_names = obs_names + adata.obs["centroid_y"] = cents[:, 0] + adata.obs["centroid_x"] = cents[:, 1] + adata.obsm["spatial"] = cents + + # defaults to avoid NameError when skipping tissue/outliers + tissue = np.zeros(n_tiles, dtype=bool) + back = ~tissue + t_sim = np.zeros(n_tiles, np.float32) + b_sim = np.zeros(n_tiles, np.float32) + outlier_labels = np.ones(n_tiles, dtype=int) + unfocus_scores: np.ndarray | None = None + + if detect_outliers: + if detect_tissue: + tissue, back, t_sim, b_sim = _detect_tissue_from_mask( + sdata, image_key, tile_indices, tg.ty, tg.tx, scale, tissue_mask_key=tissue_mask_key + ) + logger.info(f"- Classified tiles: background: {back.sum()}, tissue: {tissue.sum()}.") + + if detect_tissue and tissue.any(): + if outlier_method == "pvalue": + labels, pvals = _detect_outliers_pvalue(X, tissue_mask=tissue, var_names=var_names) + scores = 1.0 - pvals + lo, hi = float(np.min(scores)), float(np.max(scores)) + scores = (scores - lo) / (hi - lo) if hi > lo else np.zeros_like(scores) + outlier_labels = np.where(scores >= outlier_cutoff, -1, 1) + unfocus_scores = scores + elif outlier_method == "tenengrad_tissue": + scores = _detect_tenengrad_tissue_outliers(X, tissue_mask=tissue, var_names=var_names) + outlier_labels = np.where(scores >= outlier_cutoff, -1, 1) + unfocus_scores = scores + else: + tX = X[tissue] + t_labels = _detect_sharpness_outliers(tX, method=outlier_method) + outlier_labels = np.ones(n_tiles, dtype=int) + outlier_labels[tissue] = t_labels + else: + method = "zscore" if outlier_method == "pvalue" else outlier_method + outlier_labels = _detect_sharpness_outliers(X, method=method) + + adata.obs["sharpness_outlier"] = pd.Categorical( + (outlier_labels == -1).astype(str), categories=["False", "True"] + ) + if detect_tissue: + adata.obs["is_tissue"] = pd.Categorical(tissue.astype(str), categories=["False", "True"]) + adata.obs["is_background"] = pd.Categorical(back.astype(str), categories=["False", "True"]) + adata.obs["tissue_similarity"] = t_sim + adata.obs["background_similarity"] = b_sim + if unfocus_scores is not None: + adata.obs["unfocus_score"] = unfocus_scores + + logger.info(f"- Detected {int((outlier_labels == -1).sum())} outlier tiles.") + + adata.uns["qc_sharpness"] = { + "metrics": list(all_scores.keys()), + "tile_size_y": tg.ty, + "tile_size_x": tg.tx, + "image_height": H, + "image_width": W, + "n_tiles_y": tg.tiles_y, + "n_tiles_x": tg.tiles_x, + "image_key": image_key, + "scale": scale, + "detect_tissue": detect_tissue, + "outlier_method": outlier_method, + "n_tissue_tiles": int(tissue.sum()), + "n_background_tiles": int(back.sum()), + "n_outlier_tiles": int((outlier_labels == -1).sum()), + } + + table_key = f"qc_img_{image_key}_sharpness" + shapes_key = f"qc_img_{image_key}_sharpness_grid" + + sdata.tables[table_key] = TableModel.parse(adata) + logger.info(f"- Saved sharpness scores as 'sdata.tables[\"{table_key}\"]'") + + tile_gdf = gpd.GeoDataFrame( + { + "tile_id": [f"tile_x{ix}_y{iy}" for iy, ix in tile_indices], + "tile_y": tile_indices[:, 0], + "tile_x": tile_indices[:, 1], + "pixel_y0": pixel_bounds[:, 0], + "pixel_x0": pixel_bounds[:, 1], + "pixel_y1": pixel_bounds[:, 2], + "pixel_x1": pixel_bounds[:, 3], + "geometry": polys, + }, + geometry="geometry", + ) + sdata.shapes[shapes_key] = ShapesModel.parse(tile_gdf) + + sdata.tables[table_key].uns["spatialdata_attrs"] = { + "region": shapes_key, + "region_key": "grid_name", + "instance_key": "tile_id", + } + sdata.tables[table_key].obs["grid_name"] = pd.Categorical([shapes_key] * len(sdata.tables[table_key])) + sdata.tables[table_key].obs["tile_id"] = sdata.shapes[shapes_key].index + logger.info(f"- Saved tile grid as 'sdata.shapes[\"{shapes_key}\"]'") + + +def _to_gray_dask_yx(img_yxc: xr.DataArray, weights: tuple[float, float, float] = (0.2126, 0.7152, 0.0722)) -> da.Array: + """ + Convert multi-channel image to grayscale using luminance weights. + + Parameters + ---------- + img_yxc + Input image array with shape (y, x, c). + weights + RGB weights for luminance conversion. + + Returns + ------- + Grayscale image as dask array with shape (y, x). + """ + arr = img_yxc.data + if arr.ndim != 3: + raise ValueError(f"Expected image with shape `(y, x, c)`, found `{arr.shape}`.") + c = arr.shape[2] + if c == 1: + return arr[..., 0].astype(np.float32, copy=False) + rgb = arr[..., :3].astype(np.float32, copy=False) + w = da.from_array(np.asarray(weights, dtype=np.float32), chunks=(3,)) + gray = da.tensordot(rgb, w, axes=([2], [0])) + return gray.astype(np.float32, copy=False) + + +def _get_mask_from_labels(sdata: SpatialData, mask_key: str, scale: str) -> np.ndarray: + """ + Extract mask array from sdata.labels at the specified key and scale. + + Parameters + ---------- + sdata + SpatialData object. + mask_key + Key of the mask in sdata.labels. + scale + Scale level for processing. + + Returns + ------- + Mask array as numpy array with shape (y, x). + + """ + label_node = sdata.labels[mask_key] + mask_da = _get_element_data(label_node, scale, "label", mask_key) + + # Convert to numpy array if needed + if hasattr(mask_da, "compute"): + mask = np.asarray(mask_da.compute()) + elif hasattr(mask_da, "values"): + mask = np.asarray(mask_da.values) + else: + mask = np.asarray(mask_da) + + # Ensure 2D (y, x) shape - squeeze out any singleton dimensions + if mask.ndim > 2: + mask = mask.squeeze() + if mask.ndim != 2: + raise ValueError(f"Expected 2D mask with shape (y, x), got shape {mask.shape}") + return mask + + +def _detect_tissue_from_mask( + sdata: SpatialData, + image_key: str, + tile_indices: np.ndarray, + ty: int, + tx: int, + scale: str = "scale0", + tissue_mask_key: str | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Detect tissue regions from mask and classify tiles. + + Parameters + ---------- + sdata + SpatialData object. + image_key + Image key in sdata.images. + tile_indices + Tile indices array. + ty + Tile height. + tx + Tile width. + scale + Scale level for processing. + tissue_mask_key + Key of the tissue mask in sdata.labels to use. If None, tissue detection + will be performed and the mask will be added to sdata.labels. + + Returns + ------- + Tuple of (tissue_mask, background_mask, tissue_similarity, background_similarity). + """ + n_tiles = len(tile_indices) + + # If tissue_mask_key is provided, use existing mask from sdata.labels + if tissue_mask_key is None: + # Check if default mask key already exists, otherwise perform tissue detection + mask_key = f"{image_key}_tissue" + if mask_key not in sdata.labels: + # Perform tissue detection and save to sdata.labels + detect_tissue(sdata=sdata, image_key=image_key, scale=scale, inplace=True, new_labels_key=mask_key) + logger.info(f"- Saved tissue mask as 'sdata.labels[\"{mask_key}\"]'") + + mask = _get_mask_from_labels(sdata, mask_key, scale) + elif tissue_mask_key not in sdata.labels: + raise KeyError(f"Tissue mask key '{tissue_mask_key}' not found in sdata.labels") + else: + mask = _get_mask_from_labels(sdata, tissue_mask_key, scale) + if mask is None: + logger.warning("Tissue mask missing. Marking all tiles as tissue.") + t = np.ones(n_tiles, dtype=bool) + b = ~t + return t, b, np.ones(n_tiles, np.float32), np.zeros(n_tiles, np.float32) + + # Get image dimensions from the mask + H, W = mask.shape + + tissue = np.zeros(n_tiles, dtype=bool) + back = np.zeros(n_tiles, dtype=bool) + t_sim = np.zeros(n_tiles, dtype=np.float32) + b_sim = np.zeros(n_tiles, dtype=np.float32) + + for i, (iy, ix) in enumerate(tile_indices): + y0, y1 = iy * ty, min((iy + 1) * ty, H) + x0, x1 = ix * tx, min((ix + 1) * tx, W) + frac = float(np.mean(mask[y0:y1, x0:x1] > 0.0)) if (y1 > y0 and x1 > x0) else 0.0 + is_t = frac > 0.5 + tissue[i] = is_t + back[i] = not is_t + t_sim[i] = 1.0 if is_t else 0.0 + b_sim[i] = 0.0 if is_t else 1.0 + + return tissue, back, t_sim, b_sim + + +def _clean_sharpness_data(X: np.ndarray) -> np.ndarray: + """ + Clean sharpness data by handling inf/nan values and clipping outliers. + + Parameters + ---------- + X + Input sharpness data array. + + Returns + ------- + Cleaned data array with inf/nan values replaced and outliers clipped. + """ + Xc: np.ndarray = X.copy() + Xc[np.isinf(Xc)] = np.nan + for i in range(Xc.shape[1]): + col = Xc[:, i] + if np.any(np.isnan(col)): + med = np.nanmedian(col) + Xc[np.isnan(col), i] = med + lo, hi = np.percentile(Xc[:, i], [0.1, 99.9]) + clipped = np.clip(Xc[:, i], lo, hi) + Xc[:, i] = clipped + return Xc + + +def _detect_outliers_iqr(X_scaled: np.ndarray) -> np.ndarray: + """ + Detect outliers using Interquartile Range (IQR) method. + + Parameters + ---------- + X_scaled + Scaled sharpness data. + + Returns + ------- + Array with -1 for outliers, 1 for normal tiles. + """ + Q1 = np.percentile(X_scaled, 25, axis=0) + Q3 = np.percentile(X_scaled, 75, axis=0) + IQR = Q3 - Q1 + lower = Q1 - 1.5 * IQR + mask = np.any(X_scaled < lower, axis=1) + return np.where(mask, -1, 1) + + +def _detect_outliers_zscore(X_scaled: np.ndarray, threshold: float = 3.0) -> np.ndarray: + """ + Detect outliers using Z-score method. + + Parameters + ---------- + X_scaled + Scaled sharpness data. + threshold + Z-score threshold for outlier detection. + + Returns + ------- + Array with -1 for outliers, 1 for normal tiles. + """ + mask = np.any(X_scaled < -threshold, axis=1) + return np.where(mask, -1, 1) + + +def _detect_sharpness_outliers( + X: np.ndarray, method: str = "iqr", tissue_mask: np.ndarray | None = None, var_names: list[str] | None = None +) -> np.ndarray: + """ + Detect sharpness outliers using various methods. + + Parameters + ---------- + X + Sharpness data array. + method + Outlier detection method. + tissue_mask + Optional tissue mask for context-aware detection. + var_names + Variable names for metric identification. + + Returns + ------- + Array with -1 for outliers, 1 for normal tiles. + """ + Xc = _clean_sharpness_data(X) + if method == "tenengrad_tissue": + return _detect_tenengrad_tissue_outliers(Xc, tissue_mask, var_names) + if method == "pvalue": + return _detect_outliers_pvalue(Xc, tissue_mask, var_names)[0] + scaler = StandardScaler() + Xs = scaler.fit_transform(Xc) + if method == "iqr": + return _detect_outliers_iqr(Xs) + if method == "zscore": + return _detect_outliers_zscore(Xs) + raise ValueError(f"Unknown method '{method}'. Use 'iqr', 'zscore', 'tenengrad_tissue', or 'pvalue'.") + + +def _detect_tenengrad_tissue_outliers( + X: np.ndarray, tissue_mask: np.ndarray | None = None, var_names: list[str] | None = None +) -> np.ndarray: + """ + Detect outliers using Tenengrad metric with tissue context. + + Parameters + ---------- + X + Sharpness data array. + tissue_mask + Tissue mask for context-aware detection. + var_names + Variable names for metric identification. + + Returns + ------- + Array with outlier scores. + """ + if tissue_mask is None: + scaler = StandardScaler() + Xs = scaler.fit_transform(_clean_sharpness_data(X)) + return _detect_outliers_zscore(Xs) + + bg_mask = ~tissue_mask + if bg_mask.sum() == 0: + return np.zeros(len(X)) + ten_idx = None + if var_names is not None: + for i, n in enumerate(var_names): + if "tenengrad" in n.lower(): + ten_idx = i + break + if ten_idx is None: + scaler = StandardScaler() + Xs = scaler.fit_transform(_clean_sharpness_data(X)) + return _detect_outliers_zscore(Xs) + + t = X[tissue_mask, ten_idx] + b = X[bg_mask, ten_idx] + bmin, bmax = float(np.min(b)), float(np.max(b)) + rng = bmax - bmin + if rng <= 0: + mu_t, sd_t = float(np.mean(t)), float(np.std(t)) + 1e-10 + scores = np.clip((mu_t - t) / sd_t, 0, 1) + else: + norm = np.clip((t - bmin) / rng, 0, 1) + scores = 1.0 - norm + out = np.zeros(len(X)) + out[np.where(tissue_mask)[0]] = scores + return out + + +def _detect_outliers_pvalue( + X: np.ndarray, tissue_mask: np.ndarray | None = None, var_names: list[str] | None = None, alpha: float = 0.05 +) -> tuple[np.ndarray, np.ndarray]: + """ + Detect outliers using p-value method. + + Parameters + ---------- + X + Sharpness data array. + tissue_mask + Tissue mask for context-aware detection. + var_names + Variable names for metric identification. + alpha + Significance level for p-value threshold. + + Returns + ------- + Tuple of (outlier_labels, p_values). + """ + from scipy import stats + + if tissue_mask is None: + tissue_mask = np.ones(len(X), dtype=bool) + if var_names is None: + var_names = [f"metric_{i}" for i in range(X.shape[1])] + tX = X[tissue_mask] + bX = X[~tissue_mask] + if len(bX) < 10: + return np.ones(len(X), dtype=int), np.ones(len(X)) + P = np.ones((len(tX), len(var_names))) + for i in range(len(var_names)): + bg = bX[:, i] + mu, sd = float(np.mean(bg)), float(np.std(bg)) + if sd < 1e-10: + continue + P[:, i] = stats.norm.cdf(tX[:, i], loc=mu, scale=sd) + minP = np.min(P, axis=1) + fullP = np.ones(len(X)) + fullP[np.where(tissue_mask)[0]] = minP + out = np.where(fullP < alpha, -1, 1) + return out, fullP diff --git a/src/squidpy/experimental/im/_sharpness_metrics.py b/src/squidpy/experimental/im/_sharpness_metrics.py new file mode 100644 index 000000000..9063af293 --- /dev/null +++ b/src/squidpy/experimental/im/_sharpness_metrics.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum + +import numba +import numpy as np +from numba import njit +from scipy.fft import fft2, fftfreq + +# One thread to avoid clashes with Dask +numba.set_num_threads(1) + + +MetricFn = Callable[[np.ndarray], np.ndarray] + + +class SharpnessMetric(str, Enum): + TENENGRAD = "tenengrad" + VAR_OF_LAPLACIAN = "var_of_laplacian" + VARIANCE = "variance" + FFT_HIGH_FREQ_ENERGY = "fft_high_freq_energy" + HAAR_WAVELET_ENERGY = "haar_wavelet_energy" + + +def _ensure_f32_2d(x: np.ndarray) -> np.ndarray: + if x.ndim != 2: + raise ValueError("block must be 2D") + return np.ascontiguousarray(x.astype(np.float32, copy=False)) + + +@njit(cache=True, fastmath=True) +def _clamp(v: int, lo: int, hi: int) -> int: + if v < lo: + return lo + if v > hi: + return hi + return v + + +@njit(cache=True, fastmath=True) +def _tenengrad_mean(block: np.ndarray) -> np.ndarray: + """Mean Tenengrad energy using Sobel 3×3.""" + h, w = block.shape + gxk = np.array([[-1.0, 0.0, 1.0], [-2.0, 0.0, 2.0], [-1.0, 0.0, 1.0]], dtype=np.float32) + gyk = np.array([[1.0, 2.0, 1.0], [0.0, 0.0, 0.0], [-1.0, -2.0, -1.0]], dtype=np.float32) + s = 0.0 + for i in range(h): + for j in range(w): + gx = 0.0 + gy = 0.0 + for di in range(-1, 2): + for dj in range(-1, 2): + ii = _clamp(i + di, 0, h - 1) + jj = _clamp(j + dj, 0, w - 1) + v = block[ii, jj] + gx += gxk[di + 1, dj + 1] * v + gy += gyk[di + 1, dj + 1] * v + s += gx * gx + gy * gy + mean_val = s / (h * w) + return np.full_like(block, mean_val, dtype=np.float32) + + +@njit(cache=True, fastmath=True) +def _laplacian_variance(block: np.ndarray) -> np.ndarray: + """Population variance of Laplacian response.""" + h, w = block.shape + lk = np.array([[0.0, 1.0, 0.0], [1.0, -4.0, 1.0], [0.0, 1.0, 0.0]], dtype=np.float32) + n = h * w + s = 0.0 + s2 = 0.0 + for i in range(h): + for j in range(w): + y = 0.0 + for di in range(-1, 2): + for dj in range(-1, 2): + ii = _clamp(i + di, 0, h - 1) + jj = _clamp(j + dj, 0, w - 1) + y += lk[di + 1, dj + 1] * block[ii, jj] + s += y + s2 += y * y + mean = s / n + # var = E[y^2] - (E[y])^2 + var = (s2 / n) - (mean * mean) + var_val = var if var > 0.0 else 0.0 + return np.full_like(block, var_val, dtype=np.float32) + + +@njit(cache=True, fastmath=True) +def _pop_variance(block: np.ndarray) -> np.ndarray: + """Population variance of pixel intensities.""" + h, w = block.shape + n = h * w + s = 0.0 + s2 = 0.0 + for i in range(h): + for j in range(w): + v = block[i, j] + s += v + s2 += v * v + mean = s / n + var = (s2 / n) - (mean * mean) + var_val = var if var > 0.0 else 0.0 + return np.full_like(block, var_val, dtype=np.float32) + + +def _fft_high_freq_energy(block: np.ndarray) -> np.ndarray: + x = _ensure_f32_2d(block).astype(np.float64, copy=False) + m = float(x.mean()) + s = float(x.std()) + x = (x - m) / s if s > 0.0 else (x - m) + + F = fft2(x) + mag2 = (F.real * F.real) + (F.imag * F.imag) + + h, w = x.shape + fy = fftfreq(h) + fx = fftfreq(w) + ry, rx = np.meshgrid(fy, fx, indexing="ij") + r = np.hypot(ry, rx) + mask = r > 0.1 + + total = float(mag2.sum()) + if not np.isfinite(total) or total <= 1e-12: + ratio = 0.0 + else: + hi = float(mag2[mask].sum()) + ratio = hi / total if np.isfinite(hi) else 0.0 + if ratio < 0.0: + ratio = 0.0 + if ratio > 1.0: + ratio = 1.0 + return np.full_like(block, ratio, dtype=np.float32) + + +def _haar_wavelet_energy(block: np.ndarray) -> np.ndarray: + """Detail-band (LH+HL+HH) energy ratio of a single-level Haar transform.""" + x = _ensure_f32_2d(block).astype(np.float64, copy=False) + m = float(x.mean()) + s = float(x.std()) + x = (x - m) / s if s > 0.0 else (x - m) + + h, w = x.shape + if h % 2 == 1: + x = np.vstack([x, x[-1:, :]]) + h += 1 + if w % 2 == 1: + x = np.hstack([x, x[:, -1:]]) + w += 1 + + cA_h = (x[::2, :] + x[1::2, :]) / 2.0 + cH_h = (x[::2, :] - x[1::2, :]) / 2.0 + + cA = (cA_h[:, ::2] + cA_h[:, 1::2]) / 2.0 # LL + cH = (cA_h[:, ::2] - cA_h[:, 1::2]) / 2.0 # LH + cV = (cH_h[:, ::2] + cH_h[:, 1::2]) / 2.0 # HL + cD = (cH_h[:, ::2] - cH_h[:, 1::2]) / 2.0 # HH + + total = float((cA * cA).sum() + (cH * cH).sum() + (cV * cV).sum() + (cD * cD).sum()) + if not np.isfinite(total) or total <= 1e-12: + ratio = 0.0 + else: + detail = float((cH * cH).sum() + (cV * cV).sum() + (cD * cD).sum()) + ratio = detail / total if np.isfinite(detail) else 0.0 + if ratio < 0.0: + ratio = 0.0 + if ratio > 1.0: + ratio = 1.0 + return np.full_like(block, ratio, dtype=np.float32) + + +_METRICS: dict[SharpnessMetric, MetricFn] = { + SharpnessMetric.TENENGRAD: lambda a: _tenengrad_mean(_ensure_f32_2d(a)), + SharpnessMetric.VAR_OF_LAPLACIAN: lambda a: _laplacian_variance(_ensure_f32_2d(a)), + SharpnessMetric.VARIANCE: lambda a: _pop_variance(_ensure_f32_2d(a)), + SharpnessMetric.FFT_HIGH_FREQ_ENERGY: _fft_high_freq_energy, + SharpnessMetric.HAAR_WAVELET_ENERGY: _haar_wavelet_energy, +} + + +def _get_sharpness_metric_function(metric: str | SharpnessMetric) -> MetricFn: + if isinstance(metric, str): + try: + metric = SharpnessMetric(metric.lower()) + except ValueError as e: + avail = ", ".join(m.value for m in SharpnessMetric) + raise ValueError(f"Unknown metric '{metric}'. Available: {avail}") from e + return _METRICS[metric] diff --git a/src/squidpy/experimental/im/_utils.py b/src/squidpy/experimental/im/_utils.py index 8075ac3ca..71f1a41d7 100644 --- a/src/squidpy/experimental/im/_utils.py +++ b/src/squidpy/experimental/im/_utils.py @@ -1,67 +1,126 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal +import dask.array as da +import numpy as np import spatialdata as sd import xarray as xr -from spatialdata._logging import logger as logg - - -def _get_image_data( - sdata: sd.SpatialData, - image_key: str, - scale: str, +from shapely.geometry import Polygon +from spatialdata._logging import logger + +from squidpy._utils import _ensure_dim_order + + +class TileGrid: + def __init__( + self, + H: int, + W: int, + tile_size: Literal["auto"] | tuple[int, int] = "auto", + target_tiles: int = 100, + ): + self.H = int(H) + self.W = int(W) + if tile_size == "auto": + size = max(min(self.H // target_tiles, self.W // target_tiles), 100) + self.ty = int(size) + self.tx = int(size) + else: + self.ty = int(tile_size[0]) + self.tx = int(tile_size[1]) + self.tiles_y = (self.H + self.ty - 1) // self.ty + self.tiles_x = (self.W + self.tx - 1) // self.tx + + def indices(self) -> np.ndarray: + return np.array([[iy, ix] for iy in range(self.tiles_y) for ix in range(self.tiles_x)], dtype=int) + + def names(self) -> list[str]: + return [f"tile_x{ix}_y{iy}" for iy in range(self.tiles_y) for ix in range(self.tiles_x)] + + def bounds(self) -> np.ndarray: + b: list[list[int]] = [] + for iy in range(self.tiles_y): + for ix in range(self.tiles_x): + y0, x0 = iy * self.ty, ix * self.tx + y1 = (iy + 1) * self.ty if iy < self.tiles_y - 1 else self.H + x1 = (ix + 1) * self.tx if ix < self.tiles_x - 1 else self.W + b.append([y0, x0, y1, x1]) + return np.array(b, dtype=int) + + def centroids_and_polygons(self) -> tuple[np.ndarray, list[Polygon]]: + cents: list[list[float]] = [] + polys: list[Polygon] = [] + for y0, x0, y1, x1 in self.bounds(): + cy = (y0 + y1) / 2 + cx = (x0 + x1) / 2 + cents.append([cy, cx]) + polys.append(Polygon([(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)])) + return np.array(cents, dtype=float), polys + + def rechunk_and_pad(self, arr_yx: da.Array) -> da.Array: + if arr_yx.ndim != 2: + raise ValueError("Expected a 2D array shaped (y, x).") + pad_y = self.tiles_y * self.ty - int(arr_yx.shape[0]) + pad_x = self.tiles_x * self.tx - int(arr_yx.shape[1]) + a = arr_yx.rechunk((self.ty, self.tx)) + return da.pad(a, ((0, pad_y), (0, pad_x)), mode="edge") if (pad_y > 0 or pad_x > 0) else a + + def coarsen(self, arr_yx: da.Array, reduce: Literal["mean", "sum"] = "mean") -> da.Array: + reducer = np.mean if reduce == "mean" else np.sum + return da.coarsen(reducer, arr_yx, {0: self.ty, 1: self.tx}, trim_excess=False) + + +def _get_element_data( + element_node: Any, + scale: str | Literal["auto"], + element_type: str = "element", + element_key: str = "", ) -> xr.DataArray: """ - Extract image data from SpatialData object, handling both datatree and direct DataArray images. + Extract data array from a spatialdata element (image or label) node. + Supports multiscale and single-scale elements. Parameters ---------- - sdata : SpatialData - SpatialData object - image_key : str - Key in sdata.images - scale : str - Multiscale level, e.g. "scale0", or "auto" for the smallest available scale + element_node + The element node from sdata.images[key] or sdata.labels[key] + scale + Scale level to use, or "auto" for images (picks coarsest). + element_type + Type of element for error messages (e.g., "image", "label"). + element_key + Key of the element for error messages. Returns ------- - xr.DataArray - Image data in (c, y, x) format + xr.DataArray of the element data. """ - img_node = sdata.images[image_key] - - # Check if the image is a datatree (has multiple scales) or a direct DataArray - if hasattr(img_node, "keys"): - available_scales = list(img_node.keys()) + if hasattr(element_node, "keys"): # multiscale + available = list(element_node.keys()) + if not available: + raise ValueError(f"No scales for {element_type} {element_key!r}") if scale == "auto": - scale = available_scales[-1] - elif scale not in available_scales: - print(scale) - print(available_scales) - scale = available_scales[-1] - logg.warning(f"Scale '{scale}' not found, using available scale. Available scales: {available_scales}") - - img_da = img_node[scale].image - else: - # It's a direct DataArray (no scales) - img_da = img_node.image if hasattr(img_node, "image") else img_node - return _ensure_cyx(img_da) + def _idx(k: str) -> int: + num = "".join(ch for ch in k if ch.isdigit()) + return int(num) if num else -1 + chosen = max(available, key=_idx) + elif scale not in available: + logger.warning(f"Scale {scale!r} not found. Available: {available}") + # Try scale0 as fallback, otherwise use first available + chosen = "scale0" if "scale0" in available else available[0] + logger.info(f"Using scale {chosen!r}") + else: + chosen = scale -def _ensure_cyx(img_da: xr.DataArray) -> xr.DataArray: - """Ensure dims are (c, y, x). Adds a length-1 "c" if missing.""" - dims = list(img_da.dims) - if "y" not in dims or "x" not in dims: - raise ValueError(f'Expected dims to include "y" and "x". Found dims={dims}') + data = element_node[chosen].image + else: # single-scale + data = element_node.image if hasattr(element_node, "image") else element_node - # Handle case where dims are (c, y, x) - keep as is - if "c" in dims: - return img_da if dims[0] == "c" else img_da.transpose("c", "y", "x") - # If no "c" dimension, add one - return img_da.expand_dims({"c": [0]}).transpose("c", "y", "x") + return data def _flatten_channels( @@ -104,21 +163,21 @@ def _flatten_channels( # If user explicitly specifies multichannel, always use mean if channel_format == "multichannel": - logg.info(f"Converting {n_channels}-channel image to greyscale using mean across all channels") + logger.info(f"Converting {n_channels}-channel image to greyscale using mean across all channels") return img.mean(dim="c") # Handle explicit RGB specification if channel_format == "rgb": if n_channels != 3: raise ValueError(f"Cannot treat {n_channels}-channel image as RGB (requires exactly 3 channels)") - logg.info("Converting RGB image to greyscale using luminance formula") + logger.info("Converting RGB image to greyscale using luminance formula") weights = xr.DataArray([0.299, 0.587, 0.114], dims=["c"], coords={"c": img.coords["c"]}) return (img * weights).sum(dim="c") elif channel_format == "rgba": if n_channels != 4: raise ValueError(f"Cannot treat {n_channels}-channel image as RGBA (requires exactly 4 channels)") - logg.info("Converting RGBA image to greyscale using luminance formula (ignoring alpha)") + logger.info("Converting RGBA image to greyscale using luminance formula (ignoring alpha)") weights = xr.DataArray([0.299, 0.587, 0.114, 0.0], dims=["c"], coords={"c": img.coords["c"]}) return (img * weights).sum(dim="c") diff --git a/src/squidpy/experimental/pl/__init__.py b/src/squidpy/experimental/pl/__init__.py new file mode 100644 index 000000000..6d51786fa --- /dev/null +++ b/src/squidpy/experimental/pl/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from ._qc_sharpness import qc_sharpness + +__all__ = ["qc_sharpness"] diff --git a/src/squidpy/exp/pl/_qc.py b/src/squidpy/experimental/pl/_qc_sharpness.py similarity index 70% rename from src/squidpy/exp/pl/_qc.py rename to src/squidpy/experimental/pl/_qc_sharpness.py index c26753561..ae2d2b08a 100644 --- a/src/squidpy/exp/pl/_qc.py +++ b/src/squidpy/experimental/pl/_qc_sharpness.py @@ -1,28 +1,33 @@ from __future__ import annotations -from typing import Optional, Tuple +from typing import Any import matplotlib.pyplot as plt import numpy as np +from scipy.stats import gaussian_kde +from spatialdata import SpatialData from spatialdata._logging import logger as logg -from squidpy.exp.im._qc import SHARPNESS_METRICS +from squidpy.experimental.im._sharpness_metrics import SharpnessMetric +# Alias for backwards compatibility +SHARPNESS_METRICS = SharpnessMetric -def qc_sharpness_metrics( - sdata, + +def qc_sharpness( + sdata: SpatialData, image_key: str, - metrics: SHARPNESS_METRICS | list[SHARPNESS_METRICS] | None = None, + metrics: SharpnessMetric | list[SharpnessMetric] | None = None, figsize: tuple[int, int] | None = None, return_fig: bool = False, - **kwargs, + **kwargs: Any, ) -> plt.Figure | None: """ Plot a summary view of raw sharpness metrics from qc_sharpness results. Automatically scans adata.uns for calculated metrics and plots the raw sharpness values. Creates a multi-panel plot: one panel per calculated sharpness metric. - Each panel shows: spatial view, histogram, and statistics. + Each panel shows: spatial view, KDE plot, and statistics. Parameters ---------- @@ -30,9 +35,9 @@ def qc_sharpness_metrics( SpatialData object containing QC results. image_key : str Image key used in qc_sharpness function. - metrics : SHARPNESS_METRICS or list of SHARPNESS_METRICS, optional + metrics : SharpnessMetric or list of SharpnessMetric, optional Specific metrics to plot. If None, plots all calculated sharpness metrics. - Use SHARPNESS_METRICS enum values. + Use SharpnessMetric enum values. figsize : tuple, optional Figure size (width, height). Auto-calculated if None. return_fig : bool @@ -45,7 +50,6 @@ def qc_sharpness_metrics( fig : matplotlib.Figure or None The matplotlib figure object if return_fig=True, otherwise None. """ - import matplotlib.pyplot as plt # Expected keys table_key = f"qc_img_{image_key}_sharpness" @@ -73,7 +77,7 @@ def qc_sharpness_metrics( # Convert enum to string names using the same logic as main function metrics_to_plot = [] for metric in metrics_list: - metric_name = metric.name.lower() if isinstance(metric, SHARPNESS_METRICS) else metric + metric_name = metric.name.lower() if isinstance(metric, SharpnessMetric) else metric if metric_name not in calculated_metrics: raise ValueError(f"Metric '{metric_name}' not found. Available: {calculated_metrics}") metrics_to_plot.append(metric_name) @@ -122,17 +126,48 @@ def qc_sharpness_metrics( ax=ax_spatial, title=f"{metric_name.replace('_', ' ').title()}" ) ) - except Exception as e: + except (ValueError, KeyError, AttributeError) as e: logg.warning(f"Error plotting spatial view for {metric_name}: {e}") ax_spatial.text( 0.5, 0.5, f"Error plotting\n{metric_name}", ha="center", va="center", transform=ax_spatial.transAxes ) ax_spatial.set_title(f"{metric_name.replace('_', ' ').title()}") - # Panel 2: Histogram - ax_hist.hist(raw_values, bins=50, alpha=0.7, edgecolor="black") + # Panel 2: KDE plot (overlaid if tissue/background classification available) + # Create x-axis range for KDE + x_min, x_max = float(np.min(raw_values)), float(np.max(raw_values)) + x_range = np.linspace(x_min, x_max, 200) + + if "is_tissue" in adata.obs: + # Convert categorical to boolean for filtering + is_tissue = adata.obs["is_tissue"].astype(str) == "True" + tissue_values = raw_values[is_tissue] + background_values = raw_values[~is_tissue] + + # Create KDE plots for both tissue and background + if len(background_values) > 1: + kde_background = gaussian_kde(background_values) + density_background = kde_background(x_range) + ax_hist.plot(x_range, density_background, label="Background", alpha=0.7) + ax_hist.fill_between(x_range, density_background, alpha=0.3) + + if len(tissue_values) > 1: + kde_tissue = gaussian_kde(tissue_values) + density_tissue = kde_tissue(x_range) + ax_hist.plot(x_range, density_tissue, label="Tissue", alpha=0.7) + ax_hist.fill_between(x_range, density_tissue, alpha=0.3) + + ax_hist.legend() + else: + # Regular KDE plot if no tissue classification + if len(raw_values) > 1: + kde = gaussian_kde(raw_values) + density = kde(x_range) + ax_hist.plot(x_range, density, alpha=0.7) + ax_hist.fill_between(x_range, density, alpha=0.3) + ax_hist.set_xlabel(f"{metric_name.replace('_', ' ').title()}") - ax_hist.set_ylabel("Count") + ax_hist.set_ylabel("Density") ax_hist.set_title("Distribution") ax_hist.grid(True, alpha=0.3) @@ -140,20 +175,20 @@ def qc_sharpness_metrics( ax_stats.axis("off") stats_text = f""" Raw {metric_name.replace("_", " ").title()} Statistics: - + Count: {len(raw_values):,} Mean: {np.mean(raw_values):.4f} Std: {np.std(raw_values):.4f} Min: {np.min(raw_values):.4f} Max: {np.max(raw_values):.4f} - + Percentiles: 5%: {np.percentile(raw_values, 5):.4f} 25%: {np.percentile(raw_values, 25):.4f} 50%: {np.percentile(raw_values, 50):.4f} 75%: {np.percentile(raw_values, 75):.4f} 95%: {np.percentile(raw_values, 95):.4f} - + Non-zero: {np.count_nonzero(raw_values):,} Zero: {np.sum(raw_values == 0):,} """ diff --git a/tests/experimental/test_detect_tissue.py b/tests/experimental/test_detect_tissue.py index 7a54d41c5..2410beba9 100644 --- a/tests/experimental/test_detect_tissue.py +++ b/tests/experimental/test_detect_tissue.py @@ -1,5 +1,3 @@ -"""Test for experimental tissue detection.""" - from __future__ import annotations import spatialdata_plot as sdp diff --git a/tests/experimental/test_qc_sharpness.py b/tests/experimental/test_qc_sharpness.py new file mode 100644 index 000000000..bdf56f896 --- /dev/null +++ b/tests/experimental/test_qc_sharpness.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import spatialdata_plot as sdp + +import squidpy as sq +from tests.conftest import PlotTester, PlotTesterMeta + +_ = sdp + + +class TestQCSharpness(PlotTester, metaclass=PlotTesterMeta): + def test_plot_calc_qc_sharpness(self): + """Test QC sharpness on Visium H&E dataset.""" + sdata = sq.datasets.visium_hne_sdata() + + sq.experimental.im.qc_sharpness( + sdata, + image_key="hne", + # Only one metric for speed + metrics=[sq.experimental.im.SharpnessMetric.TENENGRAD], + ) + + ( + sdata.pl.render_images() + .pl.render_shapes( + "qc_img_hne_sharpness_grid", color="sharpness_outlier", groups="True", palette="red", fill_alpha=1.0 + ) + .pl.show() + ) + + def test_plot_plot_qc_sharpness(self): + """Test QC sharpness on Visium H&E dataset.""" + sdata = sq.datasets.visium_hne_sdata() + + sq.experimental.im.qc_sharpness( + sdata, + image_key="hne", + # Only one metric for speed + metrics=[sq.experimental.im.SharpnessMetric.TENENGRAD], + ) + + sq.experimental.pl.qc_sharpness( + sdata, + image_key="hne", + ) From 71bc2977594d8edeaf7a7acfebd6d8c13de0d608 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 29 Oct 2025 15:26:12 +0100 Subject: [PATCH 07/12] added images from runner --- tests/_images/QCSharpness_calc_qc_sharpness.png | Bin 0 -> 19379 bytes tests/_images/QCSharpness_plot_qc_sharpness.png | Bin 0 -> 30929 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/_images/QCSharpness_calc_qc_sharpness.png create mode 100644 tests/_images/QCSharpness_plot_qc_sharpness.png diff --git a/tests/_images/QCSharpness_calc_qc_sharpness.png b/tests/_images/QCSharpness_calc_qc_sharpness.png new file mode 100644 index 0000000000000000000000000000000000000000..46971dccb2873b8a13fbb80cfd22f797f100a88e GIT binary patch literal 19379 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 Sx&-uR9WpbvLe&_0JpLEUu=AP# literal 0 HcmV?d00001 diff --git a/tests/_images/QCSharpness_plot_qc_sharpness.png b/tests/_images/QCSharpness_plot_qc_sharpness.png new file mode 100644 index 0000000000000000000000000000000000000000..aa3edb35db560cffcf3653f9583b23beb359a999 GIT binary patch literal 30929 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 b=^T^pg8QqTBvE8|F`A5&f@HC{q2GT4>ebbw literal 0 HcmV?d00001 From 80ea59e361f2e66ebb4e242a5fc4f58f4f225c6e Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 29 Oct 2025 15:36:34 +0100 Subject: [PATCH 08/12] made logging robust to different loggers --- src/squidpy/gr/_utils.py | 6 +++++- src/squidpy/im/_feature.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/squidpy/gr/_utils.py b/src/squidpy/gr/_utils.py index e32fc716d..e7f7e0d5a 100644 --- a/src/squidpy/gr/_utils.py +++ b/src/squidpy/gr/_utils.py @@ -207,7 +207,11 @@ def _save_data(adata: AnnData, *, attr: str, key: str, data: Any, prefix: bool = else: logg.info(f" `adata.{attr}[{key!r}]`") if time is not None: - logg.info("Finish", time=time) + try: + logg.info("Finish", time=time) + except TypeError: + # Fallback for loggers that don't support the 'time' parameter + logg.info("Finish") def _extract_expression( diff --git a/src/squidpy/im/_feature.py b/src/squidpy/im/_feature.py index 0ee429e39..5ca5138ef 100644 --- a/src/squidpy/im/_feature.py +++ b/src/squidpy/im/_feature.py @@ -97,7 +97,11 @@ def calculate_image_features( )(adata, img, layer=layer, library_id=library_id, features=features, features_kwargs=features_kwargs, **kwargs) if copy: - logg.info("Finish", time=start) + try: + logg.info("Finish", time=start) + except TypeError: + # Fallback for loggers that don't support the 'time' parameter + logg.info("Finish") return res _save_data(adata, attr="obsm", key=key_added, data=res, time=start) From e609d069a94c40a7da375276c55ba510fc57dc18 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 29 Oct 2025 15:47:20 +0100 Subject: [PATCH 09/12] restored logging --- src/squidpy/im/_feature.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/squidpy/im/_feature.py b/src/squidpy/im/_feature.py index 5ca5138ef..9694ac48e 100644 --- a/src/squidpy/im/_feature.py +++ b/src/squidpy/im/_feature.py @@ -6,7 +6,7 @@ import pandas as pd from anndata import AnnData -from spatialdata._logging import logger as logg +from scanpy import logging as logg from squidpy._constants._constants import ImageFeature from squidpy._docs import d, inject_docs @@ -97,11 +97,7 @@ def calculate_image_features( )(adata, img, layer=layer, library_id=library_id, features=features, features_kwargs=features_kwargs, **kwargs) if copy: - try: - logg.info("Finish", time=start) - except TypeError: - # Fallback for loggers that don't support the 'time' parameter - logg.info("Finish") + logg.info("Finish", time=start) return res _save_data(adata, attr="obsm", key=key_added, data=res, time=start) From 78d6549513b75b6f3ca629d5eb2349406784131d Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 29 Oct 2025 15:49:16 +0100 Subject: [PATCH 10/12] restored logging --- src/squidpy/gr/_utils.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/squidpy/gr/_utils.py b/src/squidpy/gr/_utils.py index e7f7e0d5a..e32fc716d 100644 --- a/src/squidpy/gr/_utils.py +++ b/src/squidpy/gr/_utils.py @@ -207,11 +207,7 @@ def _save_data(adata: AnnData, *, attr: str, key: str, data: Any, prefix: bool = else: logg.info(f" `adata.{attr}[{key!r}]`") if time is not None: - try: - logg.info("Finish", time=time) - except TypeError: - # Fallback for loggers that don't support the 'time' parameter - logg.info("Finish") + logg.info("Finish", time=time) def _extract_expression( From df21e400a3e9b7534538d47682665f7d587905d0 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 29 Oct 2025 15:52:58 +0100 Subject: [PATCH 11/12] removed dead code --- src/squidpy/experimental/pl/_qc_sharpness.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/squidpy/experimental/pl/_qc_sharpness.py b/src/squidpy/experimental/pl/_qc_sharpness.py index ae2d2b08a..903668907 100644 --- a/src/squidpy/experimental/pl/_qc_sharpness.py +++ b/src/squidpy/experimental/pl/_qc_sharpness.py @@ -10,9 +10,6 @@ from squidpy.experimental.im._sharpness_metrics import SharpnessMetric -# Alias for backwards compatibility -SHARPNESS_METRICS = SharpnessMetric - def qc_sharpness( sdata: SpatialData, @@ -158,13 +155,12 @@ def qc_sharpness( ax_hist.fill_between(x_range, density_tissue, alpha=0.3) ax_hist.legend() - else: - # Regular KDE plot if no tissue classification - if len(raw_values) > 1: - kde = gaussian_kde(raw_values) - density = kde(x_range) - ax_hist.plot(x_range, density, alpha=0.7) - ax_hist.fill_between(x_range, density, alpha=0.3) + + elif len(raw_values) > 1: + kde = gaussian_kde(raw_values) + density = kde(x_range) + ax_hist.plot(x_range, density, alpha=0.7) + ax_hist.fill_between(x_range, density, alpha=0.3) ax_hist.set_xlabel(f"{metric_name.replace('_', ' ').title()}") ax_hist.set_ylabel("Density") From 5c72645460ba4e2524dc044804e16cdbaf798d0f Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 29 Oct 2025 16:04:27 +0100 Subject: [PATCH 12/12] edge cases that caused scverse CI to fail --- src/squidpy/datasets/_utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/squidpy/datasets/_utils.py b/src/squidpy/datasets/_utils.py index 8dcfff33d..d0257fcf4 100644 --- a/src/squidpy/datasets/_utils.py +++ b/src/squidpy/datasets/_utils.py @@ -185,7 +185,11 @@ def _extension(self) -> str: def _get_zipped_dataset(folderpath: Path, dataset_name: str, figshare_id: str) -> sd.SpatialData: """Returns a specific dataset as SpatialData object. If the file is not present on disk, it will be downloaded and extracted.""" - if not folderpath.is_dir(): + # Create directory if it doesn't exist + if not folderpath.exists(): + logg.info(f"Creating directory `{folderpath}`") + folderpath.mkdir(parents=True, exist_ok=True) + elif not folderpath.is_dir(): raise ValueError(f"Expected a directory path for `folderpath`, found: {folderpath}") download_zip = folderpath / f"{dataset_name}.zip"