From 8a089ee963b5afb1c617831adae3b7efd02a8047 Mon Sep 17 00:00:00 2001 From: Nicole Bussola Date: Tue, 21 Sep 2021 14:34:39 +0200 Subject: [PATCH 1/2] add cellularity score --- src/histolab/scorer.py | 51 +++++++++++++++- tests/integration/test_scorer.py | 99 ++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src/histolab/scorer.py b/src/histolab/scorer.py index 1f1a790d1..594094bd3 100644 --- a/src/histolab/scorer.py +++ b/src/histolab/scorer.py @@ -24,6 +24,7 @@ from .filters import image_filters as imf from .filters import morphological_filters as mof from .filters.util import mask_difference +from .masks import TissueMask from .tile import Tile try: @@ -66,15 +67,59 @@ def __call__(self, tile: Tile) -> float: return np.random.random() +class CellularityScorer(Scorer): + """Implement a basic Scorer that estimates the cellularity in an H&E-stained tile. + + This class deconvolves the hematoxylin channel and use the fraction of tile + occupied by hematoxylin as the cellularity score. + + Notice that this scorer is useful when tiles are extracted at a very low resolution + with no artifacts; in this case, NucleiScorer() would not work well as nuclei are n + o discernible at low magnification. + + .. automethod:: __call__ + """ + + def __init__(self, consider_tissue: bool = True) -> None: + self.consider_tissue = consider_tissue + + def __call__(self, tile: Tile) -> float: + """Return the tile cellularity score. + + Parameters + ---------- + tile : Tile + The tile to calculate the score from. + consider_tissue : bool + Whether the cellularity score should be computed by considering the tissue + on the tile. Default is True + + Returns + ------- + float + Cellularity score + """ + + tissue_mask = TissueMask() + filters_cellularity = imf.Compose( + [imf.HematoxylinChannel(), imf.YenThreshold(operator.gt)] + ) + + mask_nuclei = np.array(tile.apply_filters(filters_cellularity).image) + + return ( + np.count_nonzero(mask_nuclei) / np.count_nonzero(tissue_mask(tile)) + if self.consider_tissue + else np.count_nonzero(mask_nuclei) / mask_nuclei.size + ) + + class NucleiScorer(Scorer): r"""Implement a Scorer that estimates the presence of nuclei in an H&E-stained tile. This class implements an hybrid algorithm that combines thresholding and morphological operations to segment nuclei on H&E-stained histological images. - This class implements an hybrid algorithm that combines thresholding and - morphological operations to segment nuclei on H&E-stained histological images. - The NucleiScorer class defines the score of a given tile t as: .. math:: diff --git a/tests/integration/test_scorer.py b/tests/integration/test_scorer.py index 7e1c9fe29..61bd0c8ab 100644 --- a/tests/integration/test_scorer.py +++ b/tests/integration/test_scorer.py @@ -57,3 +57,102 @@ def it_knows_nuclei_score(self, tile_img, expected_score): score = nuclei_scorer(tile) assert round(score, 5) == round(expected_score, 5) + + @pytest.mark.parametrize( + "tile_img, tissue, expected_score", + ( + # level 0 + (TILES.VERY_LOW_NUCLEI_SCORE_LEVEL0, True, 8.237973509211956e-05), + (TILES.LOW_NUCLEI_SCORE_LEVEL0, True, 0.029559623811739984), + (TILES.MEDIUM_NUCLEI_SCORE_LEVEL0, True, 0.030890383076685523), + (TILES.HIGH_NUCLEI_SCORE_LEVEL0, True, 0.5452530357003211), + # level 1 + ( + TILES.VERY_LOW_NUCLEI_SCORE_RED_PEN_LEVEL1, + True, + 0.01610148106450298, + ), # breast - red pen + ( + TILES.LOW_NUCLEI_SCORE_LEVEL1, + True, + 0.1887327689375471, + ), # breast - green pen + (TILES.MEDIUM_NUCLEI_SCORE_LEVEL1, True, 0.04807442622295907), # aorta + ( + TILES.MEDIUM_NUCLEI_SCORE_LEVEL1_2, + True, + 0.23493428091089713, + ), # breast - green pen + ( + TILES.MEDIUM_NUCLEI_SCORE_GREEN_PEN_LEVEL1, + True, + 0.6710679905294349, + ), # breast - green pen + ( + TILES.HIGH_NUCLEI_SCORE_RED_PEN_LEVEL1, + True, + 0.30155274716806657, + ), # breast - red pen + # level 2 + (TILES.MEDIUM_NUCLEI_SCORE_LEVEL2, True, 0.40466129363258146), # prostate + (TILES.HIGH_NUCLEI_SCORE_LEVEL2, True, 0.906540012633011), # prostate + # no tissue + (TILES.NO_TISSUE, True, 0.05299860529986053), + (TILES.NO_TISSUE2, True, 0.003337363966142684), + (TILES.NO_TISSUE_LINE, True, 0.3471366793342943), + (TILES.NO_TISSUE_RED_PEN, True, 0.7135526324697217), + (TILES.NO_TISSUE_GREEN_PEN, True, 0.6917688745017198), + (TILES.VERY_LOW_NUCLEI_SCORE_LEVEL0, False, 8.168825750149552e-05), + (TILES.LOW_NUCLEI_SCORE_LEVEL0, False, 0.024471282958984375), + (TILES.MEDIUM_NUCLEI_SCORE_LEVEL0, False, 0.028293456864885436), + (TILES.HIGH_NUCLEI_SCORE_LEVEL0, False, 0.5430199644432031), + # level 1 + ( + TILES.VERY_LOW_NUCLEI_SCORE_RED_PEN_LEVEL1, + False, + 0.006328582763671875, + ), # breast - red pen + ( + TILES.LOW_NUCLEI_SCORE_LEVEL1, + False, + 0.064971923828125, + ), # breast - green pen + (TILES.MEDIUM_NUCLEI_SCORE_LEVEL1, False, 0.036724090576171875), + ( + TILES.MEDIUM_NUCLEI_SCORE_LEVEL1_2, + False, + 0.23455429077148438, + ), # breast - green pen + ( + TILES.MEDIUM_NUCLEI_SCORE_GREEN_PEN_LEVEL1, + False, + 0.5416870117187, + ), # breast - green pen + ( + TILES.HIGH_NUCLEI_SCORE_RED_PEN_LEVEL1, + False, + 0.2985572814941406, + ), # breast - red pen + # level 2 + (TILES.MEDIUM_NUCLEI_SCORE_LEVEL2, False, 0.3309669494628906), # prostate + (TILES.HIGH_NUCLEI_SCORE_LEVEL2, False, 0.14234542846679688), # prostate + # no tissue + (TILES.NO_TISSUE, False, 0.0002899169921875), + (TILES.NO_TISSUE2, False, 0.000263214111328125), + (TILES.NO_TISSUE_LINE, False, 0.010105133056640625), + (TILES.NO_TISSUE_RED_PEN, False, 0.4020729064941406), + (TILES.NO_TISSUE_GREEN_PEN, False, 0.48259735107421875), + ), + ) + def it_knows_cellularity_score(self, tile_img, tissue, expected_score): + tile = Tile(tile_img, None) + cell_scorer = scorer.CellularityScorer(consider_tissue=tissue) + expected_warning_regex = ( + r"Input image must be RGB. NOTE: the image will be converted to RGB before" + r" HED conversion." + ) + + with pytest.warns(UserWarning, match=expected_warning_regex): + score = cell_scorer(tile) + + assert round(score, 5) == round(expected_score, 5) From 7d43a5b1645bc3ba5d0e3f1aaf09823eb305b96e Mon Sep 17 00:00:00 2001 From: Nicole Bussola Date: Tue, 21 Sep 2021 18:19:57 +0200 Subject: [PATCH 2/2] Address comments --- src/histolab/scorer.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/histolab/scorer.py b/src/histolab/scorer.py index 594094bd3..a6280ad0c 100644 --- a/src/histolab/scorer.py +++ b/src/histolab/scorer.py @@ -68,16 +68,22 @@ def __call__(self, tile: Tile) -> float: class CellularityScorer(Scorer): - """Implement a basic Scorer that estimates the cellularity in an H&E-stained tile. + """Implement a Scorer that estimates the cellularity in an H&E-stained tile. - This class deconvolves the hematoxylin channel and use the fraction of tile + This class deconvolves the hematoxylin channel and uses the fraction of tile occupied by hematoxylin as the cellularity score. Notice that this scorer is useful when tiles are extracted at a very low resolution - with no artifacts; in this case, NucleiScorer() would not work well as nuclei are n - o discernible at low magnification. + with no artifacts; in this case, using the``NucleiScorer()`` instead would not + work well as nuclei are no discernible at low magnification. .. automethod:: __call__ + + Parameters + ---------- + consider_tissue : bool, optional + Whether the detected tissue on the tile should be considered to compute the + cellularity score. Default is True """ def __init__(self, consider_tissue: bool = True) -> None: