"""
This module performs tile-level quantification of IHC tiles, including color deconvolution, quantification of staining
intensities, H-score and the IHC-Profiler score calculation, all based on antigen-specific thresholds, and masking of
tumor-tissue areas.
"""
# Third Party
import cv2
import numpy as np
from PIL import Image
from skimage import img_as_ubyte
from skimage.color import hed2rgb, rgb2gray, rgb2hed
from skimage.exposure import histogram
# CuBATS
from cubats.config import xp
from cubats.cutils import to_numpy
[docs]
def quantify_tile(iterable):
"""This function quantifies a single input tile and returns a dictionary.
The function offers two masking modes: tile-level masking and pixel-level masking. In tile-level masking tiles that
contain sufficient tissue (mean pixel value < 230 and standard deviation > 15), will undergo stain separation and
quantification. The results will be returned in the dictionary. The 'flag' will be set to 1. If 'save_img' is True
the DAB image will additionally be saved in the specified directory. If the tile does not contain sufficient tissue
it will not be processed and the returned dictionary will only contain the tile name and a 'flag' set to 0.
In pixel-level masking the mask will be applied to the tile prior to quantification to receive a merged tile which
will then be quantified. Non-mask pixels will be set to 255.
Args:
iterable (iterable): Iterable containing the following Information on passed tile:
- index 0: Column, necessary for naming.
- index 1: Row, necessary for naming.
- index 2: Tile itself, necessary since processes cannot access shared memory.
- DAB_TILE_DIR: Directory, for saving Image, since single processes cannot access shared memory.
- save_img: Boolean, if True, DAB image will be saved in specified directory.
- antigen_profile: Antigen-specific thresholds used during quantification.
- masking_mode: Masking mode for quantification.
Returns:
dict: Dictionary containing tile results:
- Tilename (str): Name of the tile.
- Histogram (ndarray): Histogram of the tile.
- Hist_centers (ndarray): Centers of histogram bins.
- Zones (ndarray): Number of pixels in each intensity zone.
- Percentage (ndarray): Percentage of pixels in each zone.
- Score (ndarray): Score for the tile.
- Tissue Count (int): Total number of tissue pixels in the tile.
- Flag (int): Processing flag (1 if processed, 0 if not).
- Image Array (ndarray): Array of pixel values for positive pixels.
"""
# Assign local variables for better readability
col = iterable[0]
row = iterable[1]
tile = iterable[2][0]
mask = iterable[2][1]
DAB_TILE_DIR = iterable[3]
save_img = iterable[4]
antigen_profile = iterable[5]
# masking_mode = iterable[6]
tile_name = str(col) + "_" + str(row)
# Initialize Dictionary for single tile
single_tile_dict = {}
single_tile_dict["Tilename"] = tile_name
# Convert tile to numpy array
temp = tile # DEEPZOOM_OBJECT.get_tile(DEEPZOOM_LEVEL - 1, (row, col))
temp_rgb = temp.convert("RGB")
temp = xp.array(temp_rgb)
mean = xp.mean(temp)
std = xp.std(temp)
process_tile = False
# Masking mode
if mask is None:
if mean < 230 and std > 15:
process_tile = True
else:
single_tile_dict["Flag"] = 0
else:
process_tile = True
if process_tile:
# Separate stains
DAB, H, E = color_deconvolution(temp)
# Calculate pixel intensity
(hist, hist_centers, zones, percentage, score, mask_count, img_analysis) = (
evaluate_staining_intensities(DAB, antigen_profile, mask)
)
# Save image as tif in passed directory if wanted.
if save_img:
if not DAB_TILE_DIR:
raise ValueError(
"Target directory must be specified if save_img is True"
)
img = Image.fromarray(to_numpy(DAB))
DAB_TILE_DIR = f"{DAB_TILE_DIR}/{tile_name}.tif"
# print(DAB_TILE_DIR)
img.save(DAB_TILE_DIR)
# Complete dictionary
single_tile_dict["Histogram"] = hist
single_tile_dict["Hist_centers"] = hist_centers
single_tile_dict["Zones"] = zones
single_tile_dict["Percentage"] = percentage
single_tile_dict["Score"] = score
single_tile_dict["Mask Count"] = mask_count
single_tile_dict["Image Array"] = img_analysis
single_tile_dict["Flag"] = 1
return single_tile_dict
[docs]
def color_deconvolution(
ihc_rgb,
hematoxylin=False,
eosin=False,
):
"""
Separates individual stains (Hematoxylin, Eosin, DAB) from an IHC image and returns an image for each stain.
Args:
ihc_rgb (Image): IHC image in RGB format.
hematoxylin (bool): If True, returns Hematoxylin image as well. Defaults to False.
eosin (bool): If True, returns Eosin image. Defaults to False.
Returns:
tuple: A tuple containing:
- ihc_d (Image): DAB (3',3'-Diaminobenzidine) stain of the image.
- ihc_h (Image): Hematoxylin stain of the image if hematoxylin=True, otherwise None.
- ihc_e (Image): Eosin stain of the image if eosin=True, otherwise None.
"""
# Convert RGB image to HED using prebuilt skimage method
# Convert to numpy array if transformation
ihc_hed = rgb2hed(to_numpy(ihc_rgb))
ihc_hed = xp.array(ihc_hed)
# Create RGB image for each seperate stain
null = xp.zeros_like(ihc_hed[:, :, 0])
# Separate Hematoxylin stain
ihc_h = (
img_as_ubyte(
hed2rgb(
to_numpy(xp.stack((ihc_hed[:, :, 0], null, null), axis=-1)))
)
if hematoxylin
else None
)
# Separate Eosin stain
ihc_e = (
img_as_ubyte(
hed2rgb(
to_numpy(xp.stack((null, ihc_hed[:, :, 1], null), axis=-1)))
)
if eosin
else None
)
# Separate DAB stain
ihc_d = img_as_ubyte(
hed2rgb(to_numpy(xp.stack((null, null, ihc_hed[:, :, 2]), axis=-1)))
)
return ihc_d, ihc_h, ihc_e
[docs]
def evaluate_staining_intensities(image, antigen_profile, tumor_mask=None):
"""
Calculates pixel intensity of each pixel in the input image and separates them into 5 different zones based on
their intensity. The image is converted to grayscale format, resulting in a distribution of intensity values
between 0-255. Intensities above 235 are predominantly background or fatty tissues and do not contribute to
pathological scoring. Thresholds for high-positive, medium-positive, low-positive and negative pixels are defined
by the passed antigen profile. If 'masking_mode' is 'pixel-level', pixels with an intensity value of 255 will be
excluded as they mark non-mask areas.
After calculating pixel intensities this function calculates percentage contribution of each of the zones with
respect to the mask tissue in the tile, as well as the a pathology score.
Args:
image (xp.ndarray): Input image.
antigen_profile (dict): Dictionary with threshold values.
tumor_mask (xp.ndarray). Boolean tumor mask for pixel-level, None for tile-level masking.
Returns:
tuple: A tuple containing:
- hist (ndarray): Histogram of the image.
- hist_centers (ndarray): Centers of histogram bins.
- zones (xp.ndarray): Number of pixels in each intensity zone with respect to the tumor mask.
- percentage (xp.ndarray): Percentage of pixels in each intensity zone with respect to the tumor mask.
- score (str): Overall score of the tile.
- maskcount (int): Tissue count for tile-level masking. Count of masked pixels for pixel-level.
- img_analysis (xp.ndarray): Array of pixel values for multi-antigen evaluation.
"""
# Conversion to gray-scale-ubyte image
gray_scale_image = rgb2gray(image)
gray_scale_ubyte = img_as_ubyte(gray_scale_image)
# Calculate histogram
hist, hist_centers = histogram(gray_scale_image)
# Convert to xp array for processing
gray_scale_ubyte = xp.array(gray_scale_ubyte)
w, h = gray_scale_ubyte.shape
# Initilize arrays for analysis
img_analysis = xp.full((w, h), 255, dtype="float32")
zones = xp.zeros(5, dtype=xp.int32)
# Get thresholds from antigen_profile
high_thresh = antigen_profile["high_positive_threshold"]
medium_thresh = antigen_profile["medium_positive_threshold"]
low_thresh = antigen_profile["low_positive_threshold"]
# Define intensity masks
high_positive_mask = gray_scale_ubyte < high_thresh
positive_mask = (gray_scale_ubyte >= high_thresh) & (
gray_scale_ubyte < medium_thresh
)
low_positive_mask = (gray_scale_ubyte >= medium_thresh) & (
gray_scale_ubyte < low_thresh
)
negative_mask = (gray_scale_ubyte >= low_thresh) & (gray_scale_ubyte < 235)
background_mask = gray_scale_ubyte >= 235
if tumor_mask is not None:
tumor_mask = xp.array(tumor_mask, dtype=bool) # ensure boolean
high_positive_mask &= tumor_mask
positive_mask &= tumor_mask
low_positive_mask &= tumor_mask
negative_mask &= tumor_mask
background_mask &= tumor_mask
# Update img_analysis with pixel values based on intensity masks also containing background
img_analysis[high_positive_mask] = gray_scale_ubyte[high_positive_mask]
img_analysis[positive_mask] = gray_scale_ubyte[positive_mask]
img_analysis[low_positive_mask] = gray_scale_ubyte[low_positive_mask]
img_analysis[negative_mask] = gray_scale_ubyte[negative_mask]
img_analysis[background_mask] = gray_scale_ubyte[background_mask]
if tumor_mask is not None:
img_analysis[~tumor_mask] = xp.nan
# Calculate zones except non-mask
zones[0] = xp.sum(high_positive_mask)
zones[1] = xp.sum(positive_mask)
zones[2] = xp.sum(low_positive_mask)
zones[3] = xp.sum(negative_mask)
zones[4] = xp.sum(background_mask)
tissue_count = xp.sum(zones[:4])
if tumor_mask is not None:
mask_count = xp.sum(tumor_mask)
else:
mask_count = tissue_count
# Calculate pixel count and percentage
if xp.sum(zones[:4]) == 0:
percentage = xp.zeros(5, dtype=xp.int32)
percentage[4] = 100
score = "Background"
else:
percentage, score = calculate_percentage_and_score(zones)
return (
hist,
hist_centers,
zones,
percentage,
score,
int(mask_count),
to_numpy(img_analysis),
)
[docs]
def calculate_percentage_and_score(zones):
"""
Calculates the percentage of pixels in each zone relative to the tissue count (Positive tissues) and total mask
count (Background) and computes a score for each zone. If more than 66.6% of the total pixels are attributed to
a single zone, that zone's score is assigned. Else, the score for each zone is calculated using this formula:
.. math::
\\text{Score} = \\frac{(\\text{number of pixels in zone} \\times \\text{weight of zone})}{\\text{total
pixels in image}}
with weights 4 for the high positive zone, 3 for the positive zone, 2 for the low positive zone, 1 for the negative
zone, and 0 for the background. The final score is the maximum score among all zones.
Args:
zones (xp.ndarray): Array containing amount of pixels from each zone
Returns:
tuple: A tuple containing:
- percentage (xp.ndarray): Array containing the percentage of pixels in each zone.
- score (str): Name of the zone if it exceeds 66.6%, otherwise the name of the zone with the highest score.
Raises:
ValueError: If all zones have zero pixels.
"""
if xp.sum(zones) == 0:
raise ValueError("All zones have zero pixels")
tissue_count = xp.sum(zones[:4])
total_pixels = xp.sum(zones)
# Calculate percentage of pixels in each zone
percentage_tissue = (zones[:4] / tissue_count) * 100
percentage_background = (zones[4:] / total_pixels) * 100
percentage = xp.concatenate([percentage_tissue, percentage_background])
zone_names = [
"High Positive",
"Medium Positive",
"Low Positive",
"Negative",
"Background",
]
# Check if any zone exceeds 66.6% of the total pixels
if xp.any(percentage > 66.6):
max_score_index = int(xp.argmax(percentage))
return percentage, zone_names[max_score_index]
# Else calculate wheighted score for each zone
weights = xp.array([4, 3, 2, 1, 0]) # Weights for each zone
scores = (zones * weights) / tissue_count
max_score_index = int(xp.argmax(scores[:4])) # ignore background zone
return percentage, zone_names[max_score_index]
[docs]
def mask_tile(tile, mask):
"""
Masks the tile with the given mask. The mask is a binary image with the same dimensions as the tile. The function
returns the masked tile as an Image, containing the tile where the mask is positive and white where it is negative.
Args:
tile (Image): Tile to be masked
mask (Image): Mask to be applied to the tile
Returns:
Image: Masked tile
"""
# Convert tile to numpy array
tile_np = np.array(tile)
mask_np = np.array(mask)
if len(mask_np.shape) == 3:
mask_np = cv2.cvtColor(mask_np, cv2.COLOR_RGB2GRAY)
# Resize mask to tile size if needed
if mask_np.shape != tile_np.shape[:2]:
mask_np = cv2.resize(
mask_np,
(tile_np.shape[1], tile_np.shape[0]),
interpolation=cv2.INTER_NEAREST,
)
_, binary_mask = cv2.threshold(mask_np, 127, 255, cv2.THRESH_BINARY)
tumor_mask = binary_mask == 0
binary_mask_inv = cv2.bitwise_not(binary_mask)
binary_mask_inv_3ch = cv2.merge(
(binary_mask_inv, binary_mask_inv, binary_mask_inv))
masked_tile = cv2.bitwise_and(tile_np, binary_mask_inv_3ch)
white_bg = np.ones_like(tile_np) * 255
masked_tile = np.where(binary_mask_inv_3ch == 0, white_bg, masked_tile)
return Image.fromarray(masked_tile.astype(np.uint8)), tumor_mask