Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🆕 Use Annotations as a Filter for Patch Extraction #822

Merged
merged 24 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8c30e27
add patch extraction from annotations
measty Jun 13, 2024
4cec9a8
add test
measty Jun 13, 2024
9bdbe72
update docstring
measty Jun 13, 2024
87a74ca
fix deepsource and mypy
measty Jun 13, 2024
413feee
deepsource..
measty Jun 13, 2024
b007dc2
Merge branch 'develop' into patch-extractor-filter
shaneahmed Jun 21, 2024
295755f
Merge branch 'develop' into patch-extractor-filter
shaneahmed Jul 12, 2024
c665ac2
Merge branch 'develop' of https://github.com/TissueImageAnalytics/tia…
measty Jul 18, 2024
9f010be
update notebook 4
measty Jul 19, 2024
dfd6ced
add masking and filtering example to notebook 4
measty Aug 2, 2024
defb88e
update notebook 4
measty Aug 9, 2024
4a3b91f
Merge branch 'develop' into patch-extractor-filter
shaneahmed Aug 9, 2024
d0ffbdc
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 9, 2024
162151c
Merge branch 'develop' into patch-extractor-filter
shaneahmed Aug 16, 2024
f944ae6
:technologist: Ignore PLR0913 for SlidingWindowPatchExtractor
shaneahmed Aug 16, 2024
fa644b3
:memo: Update examples/04-patch-extraction.ipynb
shaneahmed Aug 16, 2024
7426eab
Merge remote-tracking branch 'origin/patch-extractor-filter' into pat…
shaneahmed Aug 16, 2024
12e91a4
update input mask types
measty Aug 16, 2024
ecb0e5c
Merge branch 'develop' into patch-extractor-filter
shaneahmed Aug 20, 2024
5b14236
:memo: Update 04-patch-extraction.ipynb with outputs.
shaneahmed Aug 23, 2024
b2a3cce
Merge branch 'develop' into patch-extractor-filter
shaneahmed Sep 20, 2024
827a45a
Merge branch 'develop' of https://github.com/TissueImageAnalytics/tia…
measty Sep 27, 2024
cba9a6c
address review
measty Sep 27, 2024
62f3566
Merge branch 'develop' into patch-extractor-filter
shaneahmed Oct 4, 2024
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
1,711 changes: 1,025 additions & 686 deletions examples/04-patch-extraction.ipynb

Large diffs are not rendered by default.

58 changes: 57 additions & 1 deletion tests/test_patch_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

import numpy as np
import pytest
from shapely.geometry import Polygon

from tiatoolbox.annotation.storage import Annotation, SQLiteStore
from tiatoolbox.tools import patchextraction
from tiatoolbox.tools.patchextraction import PatchExtractor
from tiatoolbox.utils import misc
Expand Down Expand Up @@ -322,7 +324,7 @@ def test_get_coordinates() -> None:
)
# test when output patch shape is out of bound
# but input is in bound
input_bounds, output_bounds = PatchExtractor.get_coordinates(
input_bounds, output_bounds = PatchExtractor.get_coordinates( # skipcq: PYL-E0633
image_shape=(9, 6),
patch_input_shape=(5, 5),
patch_output_shape=(4, 4),
Expand Down Expand Up @@ -512,6 +514,7 @@ def test_filter_coordinates() -> None:
def test_mask_based_patch_extractor_ndpi(
sample_ndpi: Path,
caplog: pytest.LogCaptureFixture,
tmp_path: Path,
) -> None:
"""Test SlidingWindowPatchExtractor with mask for ndpi image."""
res = 0
Expand Down Expand Up @@ -607,3 +610,56 @@ def test_mask_based_patch_extractor_ndpi(
stride=stride,
)
assert "No candidate coordinates left" in caplog.text

# test passing an annotation mask
ann = Annotation(
Polygon.from_bounds(0, 0, slide_dimensions[0], int(slide_dimensions[1] / 4)),
{"label": "region1"},
)
ann2 = Annotation(
Polygon.from_bounds(
0, int(slide_dimensions[1] / 2), slide_dimensions[0], slide_dimensions[1]
),
{"label": "region2"},
)
store = SQLiteStore(tmp_path / "test.db")
store.append_many([ann, ann2])
store.close()

patches = patchextraction.get_patch_extractor(
input_img=input_img,
input_mask=str(tmp_path / "test.db"),
method_name="slidingwindow",
patch_size=patch_size,
resolution=res,
units="level",
stride=None,
store_filter=None,
)
len_all = len(patches)

patches = patchextraction.get_patch_extractor(
input_img=input_img,
input_mask=str(tmp_path / "test.db"),
method_name="slidingwindow",
patch_size=patch_size,
resolution=res,
units="level",
stride=None,
store_filter="props['label'] == 'region2'",
)
len_region2 = len(patches)

patches = patchextraction.get_patch_extractor(
input_img=input_img,
input_mask=str(tmp_path / "test.db"),
method_name="slidingwindow",
patch_size=patch_size,
resolution=res,
units="level",
stride=None,
store_filter="props['label'] == 'region1'",
)
len_region1 = len(patches)

assert len_all > len_region2 > len_region1
2 changes: 2 additions & 0 deletions tiatoolbox/data/remote_samples.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -143,5 +143,7 @@ files:
url: [ *testdata, "annotation/test1_config.json"]
config_2:
url: [ *testdata, "annotation/test2_config.json"]
patch_annotations:
url: [ *testdata, "annotation/sample_wsi_patch_preds.db"]
nuclick-output:
url: [*modelroot, "predictions/nuclei_mask/nuclick-output.npy"]
54 changes: 49 additions & 5 deletions tiatoolbox/tools/patchextraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
from tiatoolbox import logger
from tiatoolbox.utils import misc
from tiatoolbox.utils.exceptions import MethodNotSupportedError
from tiatoolbox.utils.visualization import AnnotationRenderer
from tiatoolbox.wsicore import wsireader

if TYPE_CHECKING: # pragma: no cover
from pathlib import Path

from pandas import DataFrame

from tiatoolbox.annotation.storage import AnnotationStore
from tiatoolbox.typing import Resolution, Units


Expand Down Expand Up @@ -45,9 +47,12 @@ class ExtractorParams(TypedDict, total=False):
pad_mode: str
pad_constant_values: int | tuple[int, int]
within_bound: bool
input_mask: str | Path | np.ndarray | wsireader.VirtualWSIReader
input_mask: (
str | Path | np.ndarray | wsireader.VirtualWSIReader | AnnotationStore | None
)
stride: int | tuple[int, int]
min_mask_ratio: float
store_filter: str | None


class PointsPatchExtractorParams(TypedDict):
Expand Down Expand Up @@ -81,9 +86,12 @@ class SlidingWindowPatchExtractorParams(TypedDict):
pad_mode: str
pad_constant_values: int | tuple[int, int]
within_bound: bool
input_mask: str | Path | np.ndarray | wsireader.VirtualWSIReader | None
input_mask: (
str | Path | np.ndarray | wsireader.VirtualWSIReader | AnnotationStore | None
)
stride: int | tuple[int, int] | None
min_mask_ratio: float
store_filter: str | None


class PatchExtractorABC(ABC):
Expand Down Expand Up @@ -123,7 +131,10 @@ class PatchExtractor(PatchExtractorABC):
'morphological' options. In case of 'otsu' or
'morphological', a tissue mask is generated for the
input_image using tiatoolbox :class:`TissueMasker`
functionality.
functionality. May also be an annotation store, in which case the
mask is generated based on the annotations. All annotations are used by
default; the 'store_filter' argument can be used to specify a filter for
a subset of annotations to use to build the mask.
resolution (Resolution):
Resolution at which to read the image, default = 0. Either a
single number or a sequence of two numbers for x and y are
Expand All @@ -150,6 +161,10 @@ class PatchExtractor(PatchExtractorABC):
min_mask_ratio (float):
Area in percentage that a patch needs to contain of positive
mask to be included. Defaults to 0.
store_filter (str):
Filter to apply to the annotations when generating the mask. Default is
None, which uses all annotations. Only used if the provided mask is an
annotation store.


Attributes:
Expand Down Expand Up @@ -188,12 +203,18 @@ def __init__(
self: PatchExtractor,
input_img: str | Path | np.ndarray,
patch_size: int | tuple[int, int],
input_mask: str | Path | np.ndarray | wsireader.VirtualWSIReader | None = None,
input_mask: str
| Path
| np.ndarray
| wsireader.VirtualWSIReader
| AnnotationStore
| None = None,
resolution: Resolution = 0,
units: Units = "level",
pad_mode: str = "constant",
pad_constant_values: int | tuple[int, int] = 0,
min_mask_ratio: float = 0,
store_filter: str | None = None,
*,
within_bound: bool = False,
) -> None:
Expand All @@ -216,6 +237,22 @@ def __init__(

if input_mask is None:
self.mask = None
elif isinstance(input_mask, str) and input_mask.endswith(".db"):
# input_mask is an annotation store
renderer = AnnotationRenderer(
max_scale=10000, edge_thickness=0, where=store_filter
)
rendered_mask = wsireader.AnnotationStoreReader(
input_mask,
renderer=renderer,
info=self.wsi.info,
).slide_thumbnail()
rendered_mask = rendered_mask[:, :, 0] == 0
self.mask = wsireader.VirtualWSIReader(
rendered_mask,
info=self.wsi.info,
mode="bool",
)
elif isinstance(input_mask, str) and input_mask in {"otsu", "morphological"}:
if isinstance(self.wsi, wsireader.VirtualWSIReader):
self.mask = None
Expand Down Expand Up @@ -618,14 +655,18 @@ class SlidingWindowPatchExtractor(PatchExtractor):
min_mask_ratio (float):
Only patches with positive area percentage above this value are included.
Defaults to 0.
store_filter (str):
Filter to apply to the annotations when generating the mask. Default is
None, which uses all annotations. Only used if the provided mask is an
annotation store.

Attributes:
stride(tuple(int)):
Stride in (x, y) direction for patch extraction.

"""

def __init__(
def __init__( # noqa: PLR0913
self: SlidingWindowPatchExtractor,
input_img: str | Path | np.ndarray,
patch_size: int | tuple[int, int],
Expand All @@ -636,6 +677,7 @@ def __init__(
pad_mode: str = "constant",
pad_constant_values: int | tuple[int, int] = 0,
min_mask_ratio: float = 0,
store_filter: str | None = None,
*,
within_bound: bool = False,
) -> None:
Expand All @@ -650,6 +692,7 @@ def __init__(
pad_constant_values=pad_constant_values,
within_bound=within_bound,
min_mask_ratio=min_mask_ratio,
store_filter=store_filter,
)
if stride is None:
self.stride = self.patch_size
Expand Down Expand Up @@ -794,5 +837,6 @@ def get_patch_extractor(
"pad_constant_values": kwargs.get("pad_constant_values", 0),
"min_mask_ratio": kwargs.get("min_mask_ratio", 0),
"within_bound": kwargs.get("within_bound", False),
"store_filter": kwargs.get("store_filter"),
}
return SlidingWindowPatchExtractor(**sliding_window_patch_extractor_args)
Loading