Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ line-length = 88
target-version = "py311"

extend-exclude = ["rationai/qc/blur/piqe.py"]

force-exclude = true
[format]
# Enable reformatting of code snippets in docstrings.
docstring-code-format = true
Expand Down
42 changes: 21 additions & 21 deletions docs/getting-started/quality-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ Next, we will need to prepare the arguments for the [residual_artifacts_and_cove
function and call it on the input image:

```python linenums="9"

conversion = StandardConversions.RGB2HER
nucleus_area = 150
res_index = 2
Expand Down Expand Up @@ -89,25 +88,25 @@ from rationai.masks import tissue_mask
from rationai.qc import blur_score_piqe


img_a = np.asarray(Image.open("blur_A.png").convert("RGB")) # Focused image
img_b = np.asarray(Image.open("blur_B.png").convert("RGB")) # Partially blurred image
img_c = np.asarray(Image.open("blur_C.png").convert("RGB")) # Blurred image
img_a = np.asarray(Image.open("blur_A.png").convert("RGB")) # Focused image
img_b = np.asarray(Image.open("blur_B.png").convert("RGB")) # Partially blurred image
img_c = np.asarray(Image.open("blur_C.png").convert("RGB")) # Blurred image
```

Next, we will need to prepare the arguments for the [blur_score_piqe](../api/blur/blur-score-piqe.md) function and call it on the input images:

```python linenums="12"
pixel_size = 0.44 # pixel size of input images in micrometers
pixel_size = 0.44 # pixel size of input images in micrometers

# blur score without tissue mask
blur_score_a = blur_score_piqe(img_a, pixel_size)
blur_score_b = blur_score_piqe(img_b, pixel_size)

tissue_mask_img = tissue_mask(
pyvips.Image.new_from_array(img_c), mpp=pixel_size
pyvips.Image.new_from_array(img_c), mpp=pixel_size
).numpy()

tissue_mask_img = (tissue_mask_img > 0).astype(int) # binarize mask
tissue_mask_img = (tissue_mask_img > 0).astype(int) # binarize mask

# blur score with tissue mask
blur_score_c = blur_score_piqe(img_c, pixel_size, tissue_mask_img)
Expand All @@ -124,7 +123,6 @@ After obtaining the results, we can save the computed blur score masks.
Since `blur_score_coverage` is homogeneous across the image, we can just print out any value from the score mask for quick inspection:

```python linenums="29"

print(blur_score_a["blur_score_coverage"][0][0])

# save the coverage masks
Expand Down Expand Up @@ -205,30 +203,32 @@ from rationai.qc import folding

mpp = 1.76
img = np.asarray(Image.open("fold.png").convert("RGB")) # Investigated image
local_area_image = np.asarray(Image.open("fold_area.png").convert("RGB")) # Local area of investigated image
neigborhood_image = np.asarray(
Image.open("fold_area.png").convert("RGB")
) # Neighborhood area of investigated image
```

Now we can calculate the tissue masks:

```python linenums="12"
img_mask = tissue_mask(
pyvips.Image.new_from_array(img), mpp=mpp
).numpy() > 0
img_mask = tissue_mask(pyvips.Image.new_from_array(img), mpp=mpp).numpy() > 0

img_area_mask = tissue_mask(
pyvips.Image.new_from_array(local_area_image), mpp=mpp
).numpy() > 0
neighborhood_mask = (
tissue_mask(pyvips.Image.new_from_array(neigborhood_image), mpp=mpp).numpy() > 0
)
```

Now we have all the arguments prepared. The folding function can be called:

```python linenums="20"
artifacts = folding(img=img,
mpp=mpp,
hematoxylin_eosin_stained=True,
tissue_mask=img_mask,
local_tiles=local_area_image,
local_mask=img_area_mask)
artifacts = folding(
img=img,
mpp=mpp,
hematoxylin_eosin_stained=True,
tissue_mask=img_mask,
neighborhood_tiles=neigborhood_image,
neighborhood_mask=neighborhood_mask,
)
```

#### Results
Expand Down
28 changes: 16 additions & 12 deletions rationai/qc/blur/blur_score_laplacian.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import numpy as np
from rationai.staining import ColorConversion, convert_color
from rationai.staining import StandardConversions, convert_color
from skimage.color import rgb2gray
from skimage.filters import laplace, threshold_otsu
from skimage.morphology import binary_dilation, binary_erosion
from skimage.morphology import dilation, erosion

from rationai.qc.blur.utils import (
get_coverage_mask,
Expand Down Expand Up @@ -45,10 +45,12 @@ def blur_score_laplacian(
Note:
The returned dictionary contains the following values:

| Key | Description |
|-----------------------|---------------------------------|
| `blur_score_per_pixel`| Binary mask of the blur score. |
| `blur_score_coverage` | Coverage mask of the blur score.|
| Key | Description |
|-----------------------------|-------------------------------------------------------|
| `blur_score_per_pixel` | Binary mask of the blur score. |
| `blur_score_coverage` | Coverage mask of the blur score. |
| `number_of_examined_pixels` | Number of pixels that were evaluated by the function. |
| `number_of_flagged_pixels` | Number of pixels labeled as artifacts. |

Examples:
```python
Expand All @@ -73,7 +75,7 @@ def blur_score_laplacian(
if foreground_mask is None:
foreground_mask = simple_foreground_mask(grayscale_img)

hematoxylin, _, _ = convert_color(img, ColorConversion.RGB2HER)
hematoxylin, _, _ = convert_color(img, StandardConversions.RGB2HER)

hematoxylin_threshold = threshold_otsu(hematoxylin)
hematoxylin_mask = hematoxylin > hematoxylin_threshold
Expand All @@ -84,20 +86,20 @@ def blur_score_laplacian(
# 2 pixels wide border is removed from the pooling mask for each hematoxylin nucleus
# to ignore gradient values on the edges of the nuclei
pooling_mask = foreground_mask * ~(
binary_dilation(hematoxylin_mask, footprint=footprint)
^ binary_erosion(hematoxylin_mask, footprint=footprint)
dilation(hematoxylin_mask, footprint=footprint)
^ erosion(hematoxylin_mask, footprint=footprint)
)

blur_score = np.abs(gradient)
blur_score_per_pixel = masked_average_pooling(blur_score, pooling_mask)
blur_score_pooled = masked_average_pooling(blur_score, pooling_mask)

# Threshold was set to 5 based on empirical testing
# Can be adjusted based on the desired sensitivity
# Higher threshold means more pixels are considered blurred
blur_score_per_pixel = blur_score_per_pixel < threshold
blur_score_per_pixel = blur_score_pooled < threshold

# activity_mask is multiplied by the foreground mask to nullify background pixels
blur_score_per_pixel = blur_score_per_pixel * foreground_mask
blur_score_per_pixel *= foreground_mask

return {
"blur_score_per_pixel": blur_score_per_pixel,
Expand All @@ -106,4 +108,6 @@ def blur_score_laplacian(
detection_mask=blur_score_per_pixel,
foreground_mask=foreground_mask,
),
"number_of_examined_pixels": int(np.count_nonzero(foreground_mask)),
"number_of_flagged_pixels": int(np.count_nonzero(blur_score_per_pixel)),
}
25 changes: 15 additions & 10 deletions rationai/qc/blur/blur_score_piqe.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ def blur_score_piqe(
Note:
The returned dictionary contains the following values:

| Key | Description |
|-----------------------|---------------------------------|
| `blur_score_per_pixel`| Binary mask of the blur score. |
| `blur_score_coverage` | Coverage mask of the blur score.|
| Key | Description |
|-----------------------------|-------------------------------------------------------|
| `blur_score_per_pixel` | Binary mask of the blur score. |
| `blur_score_coverage` | Coverage mask of the blur score. |
| `number_of_examined_pixels` | Number of pixels that were evaluated by the function. |
| `number_of_flagged_pixels` | Number of pixels labeled as artifacts. |

Examples:
```python
Expand Down Expand Up @@ -77,19 +79,22 @@ def blur_score_piqe(
# Activity mask is one column shorter for images
# with dimensions that are not divisible by 16
if grayscale_img.shape != activity_mask.shape:
activity_mask = np.pad(activity_mask, ((0, 0), (0, 1)), mode="edge")
pad_h = grayscale_img.shape[0] - activity_mask.shape[0]
pad_w = grayscale_img.shape[1] - activity_mask.shape[1]

activity_mask = np.pad(activity_mask, ((0, pad_h), (0, pad_w)), mode="edge")

if foreground_mask is None:
foreground_mask = simple_foreground_mask(grayscale_img)

# activity_mask is inverted, blurred areas and empty areas (background) are set to 1
activity_mask = ~(activity_mask > 0)
# activity_mask is multiplied by the foreground mask to nullify background pixels
activity_mask = activity_mask * foreground_mask
# Invert and restrict the mask to foreground
activity_mask = foreground_mask * ~(activity_mask > 0)
Comment thread
TomasBalak marked this conversation as resolved.
Resolved

return {
"blur_score_per_pixel": activity_mask > 0,
"blur_score_per_pixel": activity_mask,
"blur_score_coverage": get_coverage_mask(
grayscale_img, activity_mask, foreground_mask
),
"number_of_examined_pixels": int(np.count_nonzero(foreground_mask)),
"number_of_flagged_pixels": int(np.count_nonzero(activity_mask)),
}
18 changes: 11 additions & 7 deletions rationai/qc/blur/blur_score_roberts.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ def blur_score_roberts(
Note:
The returned dictionary contains the following values:

| Key | Description |
|-----------------------|---------------------------------|
| `blur_score_per_pixel`| Binary mask of the blur score. |
| `blur_score_coverage` | Coverage mask of the blur score.|
| Key | Description |
|-----------------------------|-------------------------------------------------------|
| `blur_score_per_pixel` | Binary mask of the blur score. |
| `blur_score_coverage` | Coverage mask of the blur score. |
| `number_of_examined_pixels` | Number of pixels that were evaluated by the function. |
| `number_of_flagged_pixels` | Number of pixels labeled as artifacts. |

Examples:
```python
Expand Down Expand Up @@ -72,17 +74,17 @@ def blur_score_roberts(
gaussian_gradient = roberts(gaussian(grayscale_img, sigma=1))

blur_score = np.abs(gradient - gaussian_gradient)
blur_score_per_pixel = masked_average_pooling(
blur_score_pooled = masked_average_pooling(
arr=blur_score, foreground_mask=foreground_mask
)

# Threshold was set to 2 based on empirical testing
# Can be adjusted based on the desired sensitivity
# Higher threshold means more pixels are considered blurred
blur_score_per_pixel = blur_score_per_pixel < threshold
blur_score_per_pixel = blur_score_pooled < threshold

# activity_mask is multiplied by the foreground mask to nullify background pixels
blur_score_per_pixel = blur_score_per_pixel * foreground_mask
blur_score_per_pixel *= foreground_mask

return {
"blur_score_per_pixel": blur_score_per_pixel,
Expand All @@ -91,4 +93,6 @@ def blur_score_roberts(
detection_mask=blur_score_per_pixel,
foreground_mask=foreground_mask,
),
"number_of_examined_pixels": int(np.count_nonzero(foreground_mask)),
"number_of_flagged_pixels": int(np.count_nonzero(blur_score_per_pixel)),
}
25 changes: 15 additions & 10 deletions rationai/qc/blur/utils.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
from typing import Any

import numpy as np
from skimage.morphology import area_opening, binary_erosion
from numpy.typing import NDArray
from skimage.morphology import area_opening, erosion

from rationai.qc.typing import BinaryMask, GrayScaleImage, NDArray
from rationai.qc.typing import BinaryMask, FloatingPointImage


def masked_average_pooling(arr, foreground_mask):
def masked_average_pooling(
arr: FloatingPointImage, foreground_mask: BinaryMask
) -> FloatingPointImage:
"""Perform masked average pooling, creating mask of 16x16 blocks.

For images with dimensions not divisible by 16, the function pads the image
Expand Down Expand Up @@ -55,14 +60,14 @@ def masked_average_pooling(arr, foreground_mask):


def get_coverage_mask(
grayscale_img: GrayScaleImage,
tissue_img: NDArray[Any],
detection_mask: BinaryMask,
foreground_mask: BinaryMask,
) -> NDArray[np.float64]:
"""Creates a blur coverage mask based on the grayscale image and binary mask of blur detections.
) -> FloatingPointImage:
"""Creates a blur coverage mask based on the tissue image and binary mask of blur detections.

Args:
grayscale_img: Grayscale image of the tissue.
tissue_img: Image of the tissue.
detection_mask: Binary image of the blur detection.
foreground_mask: Binary mask of the tissue, where 1 represents tissue and 0 represents background.

Expand All @@ -72,7 +77,7 @@ def get_coverage_mask(
"""
foreground_area = np.count_nonzero(foreground_mask)

coverage_mask = np.ones_like(grayscale_img).astype(float)
coverage_mask = np.ones_like(tissue_img).astype(np.float64)
coverage = (
(np.sum(detection_mask * foreground_mask) / foreground_area)
if foreground_area > 0
Expand All @@ -84,7 +89,7 @@ def get_coverage_mask(


def simple_foreground_mask(
grayscale_img: NDArray[np.float64],
grayscale_img: FloatingPointImage,
threshold: float = 0.92, # ~235/255
) -> BinaryMask:
"""Creates a simple foreground mask using thresholding and morphological operations.
Expand All @@ -101,6 +106,6 @@ def simple_foreground_mask(
foreground_mask = grayscale_img < threshold

foreground_mask = area_opening(foreground_mask)
foreground_mask = binary_erosion(foreground_mask, footprint=np.ones((5, 5)))
foreground_mask = erosion(foreground_mask, footprint=np.ones((5, 5)))

return foreground_mask
Loading