Skip to content

Commit 373228d

Browse files
authored
Functions to QC histopathology images (#1036)
1 parent 8f8f736 commit 373228d

16 files changed

Lines changed: 1406 additions & 161 deletions

src/squidpy/experimental/im/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
detect_tissue,
88
)
99
from ._make_tiles import make_tiles, make_tiles_from_spots
10+
from ._qc_image import qc_image
11+
from ._qc_metrics import QCMetric
1012

1113
__all__ = [
1214
"BackgroundDetectionParams",
1315
"FelzenszwalbParams",
16+
"QCMetric",
1417
"WekaParams",
1518
"detect_tissue",
1619
"make_tiles",
1720
"make_tiles_from_spots",
21+
"qc_image",
1822
]

src/squidpy/experimental/im/_detect_tissue.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from squidpy._utils import _ensure_dim_order, _get_scale_factors, _yx_from_shape
2626

27-
from ._utils import _flatten_channels, _get_element_data
27+
from ._utils import flatten_channels, get_element_data
2828

2929

3030
class DetectTissueMethod(enum.Enum):
@@ -357,7 +357,7 @@ def detect_tissue(
357357

358358
# Load smallest available or explicit scale
359359
img_node = sdata.images[image_key]
360-
img_da = _get_element_data(img_node, scale if manual_scale else "auto", "image", image_key)
360+
img_da = get_element_data(img_node, scale if manual_scale else "auto", "image", image_key)
361361
img_src = _ensure_dim_order(img_da, "yxc")
362362
src_h = int(img_src.sizes["y"])
363363
src_w = int(img_src.sizes["x"])
@@ -375,7 +375,7 @@ def detect_tissue(
375375
# Channel flattening (greyscale) for threshold-based methods
376376
img_grey = None
377377
if method != DetectTissueMethod.WEKA:
378-
img_grey_da: xr.DataArray = _flatten_channels(img=img_src, channel_format=channel_format)
378+
img_grey_da: xr.DataArray = flatten_channels(img=img_src, channel_format=channel_format)
379379
if need_downscale:
380380
logger.info("Downscaling for faster computation.")
381381
img_grey = _downscale_with_dask(img_grey=img_grey_da, target_pixels=auto_max_pixels)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from __future__ import annotations
2+
3+
import numpy as np
4+
5+
# --- Intensity metrics (grayscale input) ---
6+
7+
8+
def brightness_mean(block: np.ndarray) -> np.ndarray:
9+
"""Mean pixel intensity of a grayscale tile."""
10+
return np.array([[float(block.mean())]], dtype=np.float32)
11+
12+
13+
def brightness_std(block: np.ndarray) -> np.ndarray:
14+
"""Standard deviation of pixel intensity of a grayscale tile."""
15+
return np.array([[float(block.std())]], dtype=np.float32)
16+
17+
18+
def entropy(block: np.ndarray) -> np.ndarray:
19+
"""Shannon entropy of pixel intensity histogram."""
20+
arr = block.ravel()
21+
lo, hi = float(arr.min()), float(arr.max())
22+
if hi - lo < 1e-10:
23+
return np.array([[0.0]], dtype=np.float32)
24+
# Quantize to 256 bins directly without storing intermediate normalized array
25+
bins = np.clip(((arr - lo) * (255.0 / (hi - lo))).astype(np.int32), 0, 255)
26+
counts = np.bincount(bins, minlength=256)
27+
probs = counts[counts > 0].astype(np.float64)
28+
probs /= probs.sum()
29+
ent = -float(np.dot(probs, np.log2(probs)))
30+
return np.array([[ent]], dtype=np.float32)
31+
32+
33+
# --- Staining metrics (RGB input, H&E only) ---
34+
35+
36+
def rgb_to_hed(block_rgb: np.ndarray) -> np.ndarray:
37+
"""Convert RGB tile to HED colour space using Beer-Lambert deconvolution.
38+
39+
Parameters
40+
----------
41+
block_rgb
42+
(ty, tx, 3) float32 array in [0, 1].
43+
44+
Returns
45+
-------
46+
(ty, tx, 3) float64 array with channels H, E, D.
47+
"""
48+
from skimage.color import rgb2hed
49+
50+
rgb_clipped = np.clip(block_rgb, 0.0, 1.0)
51+
return rgb2hed(rgb_clipped)
52+
53+
54+
def hed_metrics(block: np.ndarray) -> np.ndarray:
55+
"""Return all HED-derived metrics for one RGB tile."""
56+
hed = rgb_to_hed(block)
57+
h = hed[..., 0]
58+
e = hed[..., 1]
59+
60+
return np.array(
61+
[
62+
[
63+
[
64+
float(h.mean()),
65+
float(h.std()),
66+
float(e.mean()),
67+
float(e.std()),
68+
float(np.abs(h).mean() / (np.abs(e).mean() + 1e-10)),
69+
]
70+
]
71+
],
72+
dtype=np.float32,
73+
)
74+
75+
76+
def hematoxylin_mean(block: np.ndarray) -> np.ndarray:
77+
"""Mean hematoxylin channel intensity."""
78+
hed = rgb_to_hed(block)
79+
return np.array([[float(hed[..., 0].mean())]], dtype=np.float32)
80+
81+
82+
def hematoxylin_std(block: np.ndarray) -> np.ndarray:
83+
"""Std of hematoxylin channel intensity."""
84+
hed = rgb_to_hed(block)
85+
return np.array([[float(hed[..., 0].std())]], dtype=np.float32)
86+
87+
88+
def eosin_mean(block: np.ndarray) -> np.ndarray:
89+
"""Mean eosin channel intensity."""
90+
hed = rgb_to_hed(block)
91+
return np.array([[float(hed[..., 1].mean())]], dtype=np.float32)
92+
93+
94+
def eosin_std(block: np.ndarray) -> np.ndarray:
95+
"""Std of eosin channel intensity."""
96+
hed = rgb_to_hed(block)
97+
return np.array([[float(hed[..., 1].std())]], dtype=np.float32)
98+
99+
100+
def he_ratio(block: np.ndarray) -> np.ndarray:
101+
"""Ratio of hematoxylin to eosin mean intensity."""
102+
hed = rgb_to_hed(block)
103+
h_mean = float(np.abs(hed[..., 0]).mean())
104+
e_mean = float(np.abs(hed[..., 1]).mean())
105+
ratio = h_mean / (e_mean + 1e-10)
106+
return np.array([[ratio]], dtype=np.float32)
107+
108+
109+
# --- Artifact metrics (RGB input, H&E only) ---
110+
111+
112+
def fold_fraction(block: np.ndarray) -> np.ndarray:
113+
"""Fraction of pixels identified as tissue folds.
114+
115+
Uses HSV thresholds tuned for H&E staining: saturation > 0.4 and
116+
value < 0.3 captures the dark, saturated appearance of folded tissue.
117+
"""
118+
from skimage.color import rgb2hsv
119+
120+
rgb_clipped = np.clip(block, 0.0, 1.0)
121+
hsv = rgb2hsv(rgb_clipped)
122+
sat = hsv[..., 1]
123+
val = hsv[..., 2]
124+
fold_mask = (sat > 0.4) & (val < 0.3)
125+
frac = float(fold_mask.sum()) / max(fold_mask.size, 1)
126+
return np.array([[frac]], dtype=np.float32)
127+
128+
129+
# --- Tissue coverage (mask input) ---
130+
131+
132+
def tissue_fraction(block: np.ndarray) -> np.ndarray:
133+
"""Fraction of pixels that are tissue (nonzero) in a binary mask tile."""
134+
return np.array([[float(block.mean())]], dtype=np.float32)

0 commit comments

Comments
 (0)