From 9551b578355843203a9e696d11f4a3539d77347f Mon Sep 17 00:00:00 2001
From: Jiaqi-Lv <60471431+Jiaqi-Lv@users.noreply.github.com>
Date: Mon, 12 Feb 2024 10:18:35 +0000
Subject: [PATCH 1/6] :hammer: `mypy` type check `tools/` (#782)
Add `mpypy` checks to:
- `tiatoolbox/tools/__init__.py`
- `tiatoolbox/tools/stainextract.py`
- `tiatoolbox/tools/pyramid.py`
- `tiatoolbox/tools/tissuemask.py`
- `tiatoolbox/tools/graph.py`
---
.github/workflows/mypy-type-check.yml | 7 +++-
tiatoolbox/tools/graph.py | 25 +++++++------
tiatoolbox/tools/pyramid.py | 41 ++++++++++++---------
tiatoolbox/tools/tissuemask.py | 53 +++++++++++++--------------
tiatoolbox/utils/visualization.py | 1 +
5 files changed, 69 insertions(+), 58 deletions(-)
diff --git a/.github/workflows/mypy-type-check.yml b/.github/workflows/mypy-type-check.yml
index e87cf1c28..a22f339c5 100644
--- a/.github/workflows/mypy-type-check.yml
+++ b/.github/workflows/mypy-type-check.yml
@@ -39,4 +39,9 @@ jobs:
tiatoolbox/__main__.py \
tiatoolbox/typing.py \
tiatoolbox/tiatoolbox.py \
- tiatoolbox/utils/*.py
+ tiatoolbox/utils/*.py \
+ tiatoolbox/tools/__init__.py \
+ tiatoolbox/tools/stainextract.py \
+ tiatoolbox/tools/pyramid.py \
+ tiatoolbox/tools/tissuemask.py \
+ tiatoolbox/tools/graph.py
diff --git a/tiatoolbox/tools/graph.py b/tiatoolbox/tools/graph.py
index 6114b9b48..c3b138ddd 100644
--- a/tiatoolbox/tools/graph.py
+++ b/tiatoolbox/tools/graph.py
@@ -18,7 +18,7 @@
from numpy.typing import ArrayLike
-def delaunay_adjacency(points: ArrayLike, dthresh: Number) -> list:
+def delaunay_adjacency(points: ArrayLike, dthresh: float) -> list:
"""Create an adjacency matrix via Delaunay triangulation from a list of coordinates.
Points which are further apart than dthresh will not be connected.
@@ -28,7 +28,7 @@ def delaunay_adjacency(points: ArrayLike, dthresh: Number) -> list:
Args:
points (ArrayLike):
An nxm list of coordinates.
- dthresh (int):
+ dthresh (float):
Distance threshold for triangulation.
Returns:
@@ -57,6 +57,7 @@ def delaunay_adjacency(points: ArrayLike, dthresh: Number) -> list:
tessellation = Delaunay(points)
# Find all connected neighbours for each point in the set of
# triangles. Starting with an empty dictionary.
+ triangle_neighbours: defaultdict
triangle_neighbours = defaultdict(set)
# Iterate over each triplet of point indexes which denotes a
# triangle within the tessellation.
@@ -157,7 +158,7 @@ def edge_index_to_triangles(edge_index: ArrayLike) -> ArrayLike:
def affinity_to_edge_index(
affinity_matrix: torch.Tensor | ArrayLike,
- threshold: Number = 0.5,
+ threshold: float = 0.5,
) -> torch.tensor | ArrayLike:
"""Convert an affinity matrix (similarity matrix) to an edge index.
@@ -233,12 +234,12 @@ def _umap_reducer(graph: dict[str, ArrayLike]) -> ArrayLike:
def build(
points: ArrayLike,
features: ArrayLike,
- lambda_d: Number = 3.0e-3,
- lambda_f: Number = 1.0e-3,
- lambda_h: Number = 0.8,
- connectivity_distance: Number = 4000,
- neighbour_search_radius: Number = 2000,
- feature_range_thresh: Number | None = 1e-4,
+ lambda_d: float = 3.0e-3,
+ lambda_f: float = 1.0e-3,
+ lambda_h: float = 0.8,
+ connectivity_distance: int = 4000,
+ neighbour_search_radius: int = 2000,
+ feature_range_thresh: float | None = 1e-4,
) -> dict[str, ArrayLike]:
"""Build a graph via hybrid clustering in spatial and feature space.
@@ -416,7 +417,7 @@ def build(
@classmethod
def visualise(
- cls: SlideGraphConstructor,
+ cls: type[SlideGraphConstructor],
graph: dict[str, ArrayLike],
color: ArrayLike | str | Callable | None = None,
node_size: Number | ArrayLike | Callable = 25,
@@ -510,8 +511,8 @@ def visualise(
# Plot the nodes
plt.scatter(
*nodes.T,
- c=color(graph) if isinstance(color, Callable) else color,
- s=node_size(graph) if isinstance(node_size, Callable) else node_size,
+ c=color(graph) if callable(color) else color,
+ s=node_size(graph) if callable(node_size) else node_size,
zorder=2,
)
diff --git a/tiatoolbox/tools/pyramid.py b/tiatoolbox/tools/pyramid.py
index 1a797ebc3..a6506fb46 100644
--- a/tiatoolbox/tools/pyramid.py
+++ b/tiatoolbox/tools/pyramid.py
@@ -129,7 +129,7 @@ def level_count(self: TilePyramidGenerator) -> int:
total_level_count = super_level_count + 1 + self.sub_tile_level_count
return int(total_level_count)
- def get_thumb_tile(self: TilePyramidGenerator) -> Image:
+ def get_thumb_tile(self: TilePyramidGenerator) -> Image.Image:
"""Return a thumbnail which fits the whole slide in one tile.
The thumbnail output size has the longest edge equal to the tile
@@ -157,7 +157,7 @@ def get_tile(
pad_mode: str = "constant",
interpolation: str = "optimise",
transparent_value: int | None = None,
- ) -> Image:
+ ) -> Image.Image:
"""Get a tile at a given level and coordinate.
Note that levels are in the reverse order of those in WSIReader.
@@ -223,7 +223,7 @@ def get_tile(
)
output_size = np.repeat(output_size, 2).astype(int)
thumb = self.get_thumb_tile()
- thumb.thumbnail(output_size)
+ thumb.thumbnail((output_size[0], output_size[1]))
return thumb
slide_dimensions = np.array(self.wsi.info.slide_dimensions)
if all(slide_dimensions < [baseline_x, baseline_y]):
@@ -331,7 +331,7 @@ def save_tile(tile_path: Path, tile: Image.Image) -> None:
msg = "Unsupported compression for zip."
raise ValueError(msg)
- archive = zipfile.ZipFile(
+ zip_archive = zipfile.ZipFile(
path,
mode="w",
compression=compression2enum[compression],
@@ -343,7 +343,7 @@ def save_tile(tile_path: Path, tile: Image.Image) -> None:
tile.save(bio, format="jpeg")
bio.seek(0)
data = bio.read()
- archive.writestr(
+ zip_archive.writestr(
str(tile_path),
data,
compress_type=compression2enum[compression],
@@ -360,7 +360,7 @@ def save_tile(tile_path: Path, tile: Image.Image) -> None:
msg = "Unsupported compression for tar."
raise ValueError(msg)
- archive = tarfile.TarFile.open(path, mode=compression2mode[compression])
+ tar_archive = tarfile.TarFile.open(path, mode=compression2mode[compression])
def save_tile(tile_path: Path, tile: Image.Image) -> None:
"""Write the tile to the output zip."""
@@ -368,9 +368,9 @@ def save_tile(tile_path: Path, tile: Image.Image) -> None:
tile.save(bio, format="jpeg")
bio.seek(0)
tar_info = tarfile.TarInfo(name=str(tile_path))
- tar_info.mtime = time.time()
+ tar_info.mtime = int(time.time())
tar_info.size = bio.tell()
- archive.addfile(tarinfo=tar_info, fileobj=bio)
+ tar_archive.addfile(tarinfo=tar_info, fileobj=bio)
for level in range(self.level_count):
for x, y in np.ndindex(self.tile_grid_size(level)):
@@ -378,13 +378,17 @@ def save_tile(tile_path: Path, tile: Image.Image) -> None:
tile_path = self.tile_path(level, x, y)
save_tile(tile_path, tile)
- if container is not None:
- archive.close()
+ if container == "zip":
+ zip_archive.close()
+ if container == "tar":
+ tar_archive.close()
def __len__(self: TilePyramidGenerator) -> int:
"""Return length of instance attributes."""
- return sum(
- np.prod(self.tile_grid_size(level)) for level in range(self.level_count)
+ return int(
+ sum(
+ np.prod(self.tile_grid_size(level)) for level in range(self.level_count)
+ ),
)
def __iter__(self: TilePyramidGenerator) -> Iterator:
@@ -452,7 +456,7 @@ def tile_group(self: ZoomifyGenerator, level: int, x: int, y: int) -> int:
cumulative_sum = sum(np.prod(self.tile_grid_size(n)) for n in range(level))
index_in_level = np.ravel_multi_index((y, x), self.tile_grid_size(level)[::-1])
tile_index = cumulative_sum + index_in_level
- return tile_index // 256 # the tile group
+ return int(tile_index // 256) # the tile group
def tile_path(self: ZoomifyGenerator, level: int, x: int, y: int) -> Path:
"""Generate the Zoomify path for a specified tile.
@@ -537,7 +541,7 @@ def __init__(
mapper = {key: (*color, 1) for key, color in zip(types, colors)}
self.renderer.mapper = lambda x: mapper[x]
- def get_thumb_tile(self: AnnotationTileGenerator) -> Image:
+ def get_thumb_tile(self: AnnotationTileGenerator) -> Image.Image:
"""Return a thumbnail which fits the whole slide in one tile.
The thumbnail output size has the longest edge equal to the tile
@@ -587,7 +591,7 @@ def get_tile(
pad_mode: str | None = None,
interpolation: str | None = None,
transparent_value: int | None = None, # noqa: ARG002
- ) -> Image:
+ ) -> Image.Image:
"""Render a tile at a given level and coordinate.
Note that levels are in the reverse order of those in WSIReader.
@@ -646,20 +650,21 @@ def get_tile(
scale = self.level_downsample(level)
baseline_x = (x * self.tile_size * scale) - (self.overlap * scale)
baseline_y = (y * self.tile_size * scale) - (self.overlap * scale)
- coord = [baseline_x, baseline_y]
+ coord = (int(baseline_x), int(baseline_y))
if level < self.sub_tile_level_count:
output_size = self.output_tile_size // 2 ** (
self.sub_tile_level_count - level
)
output_size = np.repeat(output_size, 2).astype(int)
thumb = self.get_thumb_tile()
- thumb.thumbnail(output_size)
+ thumb.thumbnail((output_size[0], output_size[1]))
return thumb
slide_dimensions = np.array(self.info.slide_dimensions)
if all(slide_dimensions < [baseline_x, baseline_y]):
raise IndexError
- bounds = locsize2bounds(coord, [self.output_tile_size * scale] * 2)
+ size = [self.output_tile_size * scale] * 2
+ bounds = locsize2bounds(coord, (int(size[0]), int(size[1])))
tile = self.renderer.render_annotations(
self.store,
bounds,
diff --git a/tiatoolbox/tools/tissuemask.py b/tiatoolbox/tools/tissuemask.py
index ac99490d8..c2ea74d80 100644
--- a/tiatoolbox/tools/tissuemask.py
+++ b/tiatoolbox/tools/tissuemask.py
@@ -18,11 +18,6 @@ class TissueMasker(ABC):
"""
- def __init__(self: TissueMasker) -> None:
- """Initialize :class:`TissueMasker`."""
- super().__init__()
- self.fitted = False
-
@abstractmethod
def fit(
self: TissueMasker,
@@ -55,9 +50,6 @@ def transform(self: TissueMasker, images: np.ndarray) -> np.ndarray:
e.g. regions of tissue vs background.
"""
- if not self.fitted:
- msg = "Fit must be called before transform."
- raise SyntaxError(msg)
def fit_transform(
self: TissueMasker,
@@ -76,7 +68,7 @@ def fit_transform(
**kwargs (dict):
Other key word arguments passed to fit.
"""
- self.fit(images, **kwargs)
+ self.fit(images, masks=None, **kwargs)
return self.transform(images)
@@ -97,13 +89,15 @@ class OtsuTissueMasker(TissueMasker):
"""
- def __init__(self: TissueMasker) -> None:
+ def __init__(self: OtsuTissueMasker) -> None:
"""Initialize :class:`OtsuTissueMasker`."""
- super().__init__()
+ self.threshold: float | None
+ self.fitted: bool
self.threshold = None
+ self.fitted = False
def fit(
- self: TissueMasker,
+ self: OtsuTissueMasker,
images: np.ndarray,
masks: np.ndarray | None = None, # noqa: ARG002
) -> None:
@@ -141,7 +135,7 @@ def fit(
self.fitted = True
- def transform(self: TissueMasker, images: np.ndarray) -> np.ndarray:
+ def transform(self: OtsuTissueMasker, images: np.ndarray) -> np.ndarray:
"""Create masks using the threshold found during :func:`fit`.
Args:
@@ -155,7 +149,9 @@ def transform(self: TissueMasker, images: np.ndarray) -> np.ndarray:
channels).
"""
- super().transform(images)
+ if not self.fitted:
+ msg = "Fit must be called before transform."
+ raise SyntaxError(msg)
masks = []
for image in images:
@@ -165,7 +161,7 @@ def transform(self: TissueMasker, images: np.ndarray) -> np.ndarray:
mask = (grey < self.threshold).astype(bool)
masks.append(mask)
- return masks
+ return np.array(masks)
class MorphologicalMasker(OtsuTissueMasker):
@@ -206,7 +202,7 @@ class MorphologicalMasker(OtsuTissueMasker):
"""
def __init__(
- self: TissueMasker,
+ self: MorphologicalMasker,
*,
mpp: float | tuple[float, float] | None = None,
power: float | tuple[float, float] | None = None,
@@ -250,18 +246,19 @@ def __init__(
# Convert MPP to an integer kernel_size
if mpp is not None:
- mpp = np.array(mpp)
- if mpp.size != 2: # noqa: PLR2004
- mpp = mpp.repeat(2)
- kernel_size = np.max([32 / mpp, [1, 1]], axis=0)
+ mpp_array = np.array(mpp)
+ if mpp_array.size != 2: # noqa: PLR2004
+ mpp_array = mpp_array.repeat(2)
+ kernel_size = np.max([32 / mpp_array, [1, 1]], axis=0)
# Ensure kernel_size is a length 2 numpy array
- kernel_size = np.array(kernel_size)
- if kernel_size.size != 2: # noqa: PLR2004
- kernel_size = kernel_size.repeat(2)
+ kernel_size_array = np.array(kernel_size)
+ if kernel_size_array.size != 2: # noqa: PLR2004
+ kernel_size_array = kernel_size_array.repeat(2)
# Convert to an integer double/ pair
- self.kernel_size = tuple(np.round(kernel_size).astype(int))
+ self.kernel_size: tuple[int, int]
+ self.kernel_size = tuple(np.round(kernel_size_array).astype(int))
# Create structuring element for morphological operations
self.kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, self.kernel_size)
@@ -270,7 +267,7 @@ def __init__(
if self.min_region_size is None:
self.min_region_size = np.sum(self.kernel)
- def transform(self: TissueMasker, images: np.ndarray) -> None:
+ def transform(self: MorphologicalMasker, images: np.ndarray) -> np.ndarray:
"""Create masks using the found threshold followed by morphological operations.
Args:
@@ -284,7 +281,9 @@ def transform(self: TissueMasker, images: np.ndarray) -> None:
channels).
"""
- super().transform(images)
+ if not self.fitted:
+ msg = "Fit must be called before transform."
+ raise SyntaxError(msg)
results = []
for image in images:
@@ -304,4 +303,4 @@ def transform(self: TissueMasker, images: np.ndarray) -> None:
mask = cv2.morphologyEx(mask, cv2.MORPH_DILATE, self.kernel)
results.append(mask.astype(bool))
- return results
+ return np.array(results)
diff --git a/tiatoolbox/utils/visualization.py b/tiatoolbox/utils/visualization.py
index 3e7c9da46..e75b7376c 100644
--- a/tiatoolbox/utils/visualization.py
+++ b/tiatoolbox/utils/visualization.py
@@ -633,6 +633,7 @@ def __init__( # noqa: PLR0913
self.secondary_cmap = secondary_cmap
self.blur_radius = blur_radius
self.function_mapper = function_mapper
+ self.blur: ImageFilter.GaussianBlur | None
if blur_radius > 0:
self.blur = ImageFilter.GaussianBlur(blur_radius)
self.edge_thickness = 0
From 23fb2a7a98a3b6257b0069f4a73b33c8db66aab1 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Mon, 12 Feb 2024 10:44:27 +0000
Subject: [PATCH 2/6] [pre-commit.ci] pre-commit autoupdate (#781)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.1.14 → v0.2.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.14...v0.2.0)
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* :hammer: Fix settings for ruff
Signed-off-by: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com>
* :bug: Fix SIM401 Use `kwargs.get("chunks", 10000)` instead of an `if` block
Signed-off-by: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com>
* :bug: SIM401 Use `clinical_info.get(v, np.nan)` instead of an `if` block
Signed-off-by: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com>
* :bug: Fix unnecessary SIM911 fix by `ruff`.
Signed-off-by: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com>
---------
Signed-off-by: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com>
---
.github/workflows/python-package.yml | 2 +-
.pre-commit-config.yaml | 2 +-
benchmarks/annotation_nquery.ipynb | 2 +-
benchmarks/annotation_store.ipynb | 4 +-
benchmarks/annotation_store_alloc.py | 10 ++--
examples/full-pipelines/slide-graph.ipynb | 2 +-
pyproject.toml | 18 +++----
requirements/requirements_dev.txt | 2 +-
tests/test_dsl.py | 62 +++++++++++-----------
tests/test_wsireader.py | 4 +-
tiatoolbox/annotation/storage.py | 4 +-
tiatoolbox/utils/misc.py | 2 +-
tiatoolbox/visualization/bokeh_app/main.py | 12 ++---
13 files changed, 64 insertions(+), 62 deletions(-)
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 2b61c7bb4..321316040 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -30,7 +30,7 @@ jobs:
sudo apt update
sudo apt-get install -y libopenslide-dev openslide-tools libopenjp2-7 libopenjp2-tools
python -m pip install --upgrade pip
- python -m pip install ruff==0.1.13 pytest pytest-cov pytest-runner
+ python -m pip install ruff==0.2.1 pytest pytest-cov pytest-runner
pip install -r requirements/requirements.txt
- name: Cache tiatoolbox static assets
uses: actions/cache@v3
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index bc0650353..935cf209b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -68,7 +68,7 @@ repos:
language: python
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
- rev: v0.1.14
+ rev: v0.2.1
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
diff --git a/benchmarks/annotation_nquery.ipynb b/benchmarks/annotation_nquery.ipynb
index 458ecbb22..64a58794a 100644
--- a/benchmarks/annotation_nquery.ipynb
+++ b/benchmarks/annotation_nquery.ipynb
@@ -71,7 +71,7 @@
"from shapely.geometry import Polygon\n",
"\n",
"sys.path.append(\"..\") # If running locally without pypi installed tiatoolbox\n",
- "from tiatoolbox.annotation.storage import ( # noqa: E402\n",
+ "from tiatoolbox.annotation.storage import (\n",
" Annotation,\n",
" AnnotationStore,\n",
" DictionaryStore,\n",
diff --git a/benchmarks/annotation_store.ipynb b/benchmarks/annotation_store.ipynb
index 128ad387c..6c8b83d65 100644
--- a/benchmarks/annotation_store.ipynb
+++ b/benchmarks/annotation_store.ipynb
@@ -207,8 +207,8 @@
"\n",
"sys.path.append(\"..\") # If running locally without pypi installed tiatoolbox\n",
"\n",
- "from tiatoolbox import logger # noqa: E402\n",
- "from tiatoolbox.annotation.storage import ( # noqa: E402\n",
+ "from tiatoolbox import logger\n",
+ "from tiatoolbox.annotation.storage import (\n",
" Annotation,\n",
" DictionaryStore,\n",
" SQLiteStore,\n",
diff --git a/benchmarks/annotation_store_alloc.py b/benchmarks/annotation_store_alloc.py
index 41b85043f..d5b6df9cb 100644
--- a/benchmarks/annotation_store_alloc.py
+++ b/benchmarks/annotation_store_alloc.py
@@ -139,12 +139,12 @@ def __exit__(self: memray, *args: object) -> None:
# Intentionally blank.
-import numpy as np # noqa: E402
-import psutil # noqa: E402
-from shapely.geometry import Polygon # noqa: E402
-from tqdm import tqdm # noqa: E402
+import numpy as np
+import psutil
+from shapely.geometry import Polygon
+from tqdm import tqdm
-from tiatoolbox.annotation.storage import ( # noqa: E402
+from tiatoolbox.annotation.storage import (
Annotation,
DictionaryStore,
SQLiteStore,
diff --git a/examples/full-pipelines/slide-graph.ipynb b/examples/full-pipelines/slide-graph.ipynb
index de6f2b60f..54d1cdbde 100644
--- a/examples/full-pipelines/slide-graph.ipynb
+++ b/examples/full-pipelines/slide-graph.ipynb
@@ -397,7 +397,7 @@
"# https://docs.gdc.cancer.gov/Encyclopedia/pages/TCGA_Barcode/\n",
"wsi_patient_codes = np.array([\"-\".join(v.split(\"-\")[:3]) for v in wsi_names])\n",
"wsi_labels = np.array(\n",
- " [clinical_info[v] if v in clinical_info else np.nan for v in wsi_patient_codes],\n",
+ " [clinical_info.get(v, np.nan) for v in wsi_patient_codes],\n",
")\n",
"\n",
"# * Filter the WSIs and paths that do not have labels\n",
diff --git a/pyproject.toml b/pyproject.toml
index dbf71f456..05463efe8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -72,7 +72,7 @@ search = 'TOOLBOX_VER: {current_version}'
replace = 'TOOLBOX_VER: {new_version}'
[tool.ruff]
-select = [
+lint.select = [
"A", # flake8-builtins
"B", # flake8-bugbear
"D", # pydocstyle, need to enable for docstrings check.
@@ -126,13 +126,13 @@ select = [
"SLOT", # flake8-slots
"ASYNC", # flake8-async
]
-ignore = []
+lint.ignore = []
# Allow Ruff to discover `*.ipynb` files.
include = ["*.py", "*.pyi", "**/pyproject.toml", "*.ipynb"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
-fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"]
-unfixable = []
+lint.fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"]
+lint.unfixable = []
# Exclude a variety of commonly ignored directories.
exclude = [
@@ -149,27 +149,27 @@ exclude = [
]
# Ignore `F401` (import violations) in all `__init__.py` files.
-per-file-ignores = {"__init__.py" = ["F401"], "tests/*" = ["T201", "PGH001", "SLF001", "S101", "PLR2004"], "benchmarks/*" = ["T201", "INP001"], "pre-commit/*" = ["T201", "INP001"], "tiatoolbox/cli/*" = ["PLR0913"]}
+lint.per-file-ignores = {"__init__.py" = ["F401"], "tests/*" = ["T201", "PGH001", "SLF001", "S101", "PLR2004"], "benchmarks/*" = ["T201", "INP001"], "pre-commit/*" = ["T201", "INP001"], "tiatoolbox/cli/*" = ["PLR0913"]}
# Same as Black.
line-length = 88
# Allow unused variables when underscore-prefixed.
-dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
+lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# Minimum Python version 3.8.
target-version = "py38"
-[tool.ruff.mccabe]
+[tool.ruff.lint.mccabe]
# Unlike Flake8, default to a complexity level of 10.
max-complexity = 14
# need to enable for docstrings check.
-[tool.ruff.pydocstyle]
+[tool.ruff.lint.pydocstyle]
# Use Google-style docstrings.
convention = "google"
-[tool.ruff.pylint]
+[tool.ruff.lint.pylint]
max-args = 10
[tool.mypy]
diff --git a/requirements/requirements_dev.txt b/requirements/requirements_dev.txt
index 6911165c5..7c58e0703 100644
--- a/requirements/requirements_dev.txt
+++ b/requirements/requirements_dev.txt
@@ -12,7 +12,7 @@ pytest>=7.2.0
pytest-cov>=4.0.0
pytest-runner>=6.0
pytest-xdist[psutil]
-ruff==0.1.13 # This will be updated by pre-commit bot to latest version
+ruff==0.2.1 # This will be updated by pre-commit bot to latest version
toml>=0.10.2
twine>=4.0.1
wheel>=0.37.1
diff --git a/tests/test_dsl.py b/tests/test_dsl.py
index e09753556..ad811ac6e 100644
--- a/tests/test_dsl.py
+++ b/tests/test_dsl.py
@@ -101,7 +101,7 @@ class TestSQLite:
@staticmethod
def test_prop_or_prop() -> None:
"""Test OR operator between two prop accesses."""
- query = eval( # skipcq: PYL-W0123 # noqa: S307
+ query = eval( # skipcq: PYL-W0123
"(props['int'] == 2) | (props['int'] == 3)",
SQL_GLOBALS,
{},
@@ -143,7 +143,7 @@ def test_number_binary_operations(
"""Check that binary operations between ints does not error."""
for op in BINARY_OP_STRINGS:
query = f"2 {op} 2"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -159,7 +159,7 @@ def test_property_binary_operations(
"""Check that binary operations between properties does not error."""
for op in BINARY_OP_STRINGS:
query = f"props['int'] {op} props['int']"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -175,7 +175,7 @@ def test_r_binary_operations(
"""Test right hand binary operations between numbers and properties."""
for op in BINARY_OP_STRINGS:
query = f"2 {op} props['int']"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -191,7 +191,7 @@ def test_number_prefix_operations(
"""Test prefix operations on numbers."""
for op in PREFIX_OP_STRINGS:
query = f"{op}1"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -207,7 +207,7 @@ def test_property_prefix_operations(
"""Test prefix operations on properties."""
for op in PREFIX_OP_STRINGS:
query = f"{op}props['int']"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -222,7 +222,7 @@ def test_regex_nested_props(
) -> None:
"""Test regex on nested properties."""
query = "props['nesting']['fib'][4]"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -237,7 +237,7 @@ def test_regex_str_props(
) -> None:
"""Test regex on string properties."""
query = "regexp('Hello', props['string'])"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -252,7 +252,7 @@ def test_regex_str_str(
) -> None:
"""Test regex on string and string."""
query = "regexp('Hello', 'Hello world!')"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -267,7 +267,7 @@ def test_regex_props_str(
) -> None:
"""Test regex on property and string."""
query = "regexp(props['string'], 'Hello world!')"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -282,7 +282,7 @@ def test_regex_ignore_case(
) -> None:
"""Test regex with ignorecase flag."""
query = "regexp('hello', props['string'], re.IGNORECASE)"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -297,7 +297,7 @@ def test_regex_no_match(
) -> None:
"""Test regex with no match."""
query = "regexp('Yello', props['string'])"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -312,7 +312,7 @@ def test_has_key(
) -> None:
"""Test has_key function."""
query = "has_key(props, 'foo')"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -327,7 +327,7 @@ def test_is_none(
) -> None:
"""Test is_none function."""
query = "is_none(props['null'])"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -342,7 +342,7 @@ def test_is_not_none(
) -> None:
"""Test is_not_none function."""
query = "is_not_none(props['int'])"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -357,7 +357,7 @@ def test_nested_has_key(
) -> None:
"""Test nested has_key function."""
query = "has_key(props['dict'], 'a')"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -372,7 +372,7 @@ def test_list_sum(
) -> None:
"""Test sum function on a list."""
query = "sum(props['list'])"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -387,7 +387,7 @@ def test_abs(
) -> None:
"""Test abs function."""
query = "abs(props['neg'])"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -402,7 +402,7 @@ def test_not(
) -> None:
"""Test not operator."""
query = "not props['bool']"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -417,7 +417,7 @@ def test_props_int_keys(
) -> None:
"""Test props with int keys."""
query = "props['list'][1]"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -432,7 +432,7 @@ def test_props_get(
) -> None:
"""Test props.get function."""
query = "is_none(props.get('foo'))"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -447,7 +447,7 @@ def test_props_get_default(
) -> None:
"""Test props.get function with default."""
query = "props.get('foo', 42)"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -462,7 +462,7 @@ def test_in_list(
) -> None:
"""Test in operator for list."""
query = "1 in props.get('list')"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -478,7 +478,7 @@ def test_has_key_exception(
"""Test has_key function with exception."""
query = "has_key(1, 'a')"
with pytest.raises(TypeError, match="(not iterable)|(Unsupported type)"):
- _ = eval( # skipcq: PYL-W0123 # noqa: S307
+ _ = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -492,7 +492,7 @@ def test_logical_and(
) -> None:
"""Test logical and operator."""
query = "props['bool'] & is_none(props['null'])"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -507,7 +507,7 @@ def test_logical_or(
) -> None:
"""Test logical or operator."""
query = "props['bool'] | (props['int'] < 2)"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -522,7 +522,7 @@ def test_nested_logic(
) -> None:
"""Test nested logical operators."""
query = "(props['bool'] | (props['int'] < 2)) & abs(props['neg'])"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -537,7 +537,7 @@ def test_contains_list(
) -> None:
"""Test contains operator for list."""
query = "1 in props['list']"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -552,7 +552,7 @@ def test_contains_dict(
) -> None:
"""Test contains operator for dict."""
query = "'a' in props['dict']"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -567,7 +567,7 @@ def test_contains_str(
) -> None:
"""Test contains operator for str."""
query = "'Hello' in props['string']"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
@@ -582,7 +582,7 @@ def test_key_with_period(
) -> None:
"""Test key with period."""
query = "props['dot.key']"
- result = eval( # skipcq: PYL-W0123 # noqa: S307
+ result = eval( # skipcq: PYL-W0123
query,
eval_globals,
eval_locals,
diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py
index 390751b5d..76a5d3861 100644
--- a/tests/test_wsireader.py
+++ b/tests/test_wsireader.py
@@ -204,7 +204,7 @@ def read_bounds_level_consistency(wsi: WSIReader, bounds: IntBounds) -> None:
# from interpolation when calculating the downsampled levels. This
# adds some tolerance for the comparison.
blurred = [cv2.GaussianBlur(img, (5, 5), cv2.BORDER_REFLECT) for img in resized]
- as_float = [img.astype(np.float_) for img in blurred]
+ as_float = [img.astype(np.float64) for img in blurred]
# Pair-wise check resolutions for mean squared error
for i, a in enumerate(as_float):
@@ -2646,7 +2646,7 @@ def test_read_rect_level_consistency(wsi: WSIReader) -> None:
# from interpolation when calculating the downsampled levels. This
# adds some tolerance for the comparison.
blurred = [cv2.GaussianBlur(img, (5, 5), cv2.BORDER_REFLECT) for img in resized]
- as_float = [img.astype(np.float_) for img in blurred]
+ as_float = [img.astype(np.float64) for img in blurred]
# Pair-wise check resolutions for mean squared error
for i, a in enumerate(as_float):
diff --git a/tiatoolbox/annotation/storage.py b/tiatoolbox/annotation/storage.py
index 9863e08d3..3fb786374 100644
--- a/tiatoolbox/annotation/storage.py
+++ b/tiatoolbox/annotation/storage.py
@@ -2028,7 +2028,9 @@ def transform(
transformed_geoms = {
key: transform(annotation.geometry) for key, annotation in self.items()
}
- self.patch_many(transformed_geoms.keys(), transformed_geoms.values())
+ _keys = transformed_geoms.keys()
+ _values = transformed_geoms.values()
+ self.patch_many(_keys, _values)
def __del__(self: AnnotationStore) -> None:
"""Implements destructor method.
diff --git a/tiatoolbox/utils/misc.py b/tiatoolbox/utils/misc.py
index 9d0c2de97..5164c7917 100644
--- a/tiatoolbox/utils/misc.py
+++ b/tiatoolbox/utils/misc.py
@@ -1327,7 +1327,7 @@ def dict_to_zarr(
compressor = (
kwargs["compressor"] if "compressor" in kwargs else numcodecs.Zstd(level=1)
)
- chunks = kwargs["chunks"] if "chunks" in kwargs else 10000
+ chunks = kwargs.get("chunks", 10000)
# ensure proper zarr extension
save_path = save_path.parent.absolute() / (save_path.stem + ".zarr")
diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py
index 608dc23a9..0f29a4aea 100644
--- a/tiatoolbox/visualization/bokeh_app/main.py
+++ b/tiatoolbox/visualization/bokeh_app/main.py
@@ -64,14 +64,14 @@
# GitHub actions seems unable to find TIAToolbox unless this is here
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
-from tiatoolbox import logger # noqa: E402
-from tiatoolbox.models.engine.nucleus_instance_segmentor import ( # noqa: E402
+from tiatoolbox import logger
+from tiatoolbox.models.engine.nucleus_instance_segmentor import (
NucleusInstanceSegmentor,
)
-from tiatoolbox.tools.pyramid import ZoomifyGenerator # noqa: E402
-from tiatoolbox.utils.visualization import random_colors # noqa: E402
-from tiatoolbox.visualization.ui_utils import get_level_by_extent # noqa: E402
-from tiatoolbox.wsicore.wsireader import WSIReader # noqa: E402
+from tiatoolbox.tools.pyramid import ZoomifyGenerator
+from tiatoolbox.utils.visualization import random_colors
+from tiatoolbox.visualization.ui_utils import get_level_by_extent
+from tiatoolbox.wsicore.wsireader import WSIReader
if TYPE_CHECKING: # pragma: no cover
from bokeh.document import Document
From b6a371ba671859280642b07666f5363c461e76e2 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Tue, 13 Feb 2024 10:41:45 +0000
Subject: [PATCH 3/6] [pre-commit.ci] pre-commit autoupdate (#784)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
updates:
- [github.com/psf/black: 24.1.1 → 24.2.0](https://github.com/psf/black/compare/24.1.1...24.2.0)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
---
.pre-commit-config.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 935cf209b..ba7ff479f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -59,7 +59,7 @@ repos:
- id: rst-directive-colons # Detect mistake of rst directive not ending with double colon.
- id: rst-inline-touching-normal # Detect mistake of inline code touching normal text in rst.
- repo: https://github.com/psf/black
- rev: 24.1.1 # Replace with any tag/version: https://github.com/psf/black/tags
+ rev: 24.2.0 # Replace with any tag/version: https://github.com/psf/black/tags
hooks:
- id: black
language_version: python3 # Should be a command that runs python3.+
From 5c00381797a392204ae8f4501445e628034462fc Mon Sep 17 00:00:00 2001
From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com>
Date: Tue, 20 Feb 2024 10:02:04 +0000
Subject: [PATCH 4/6] :pushpin: Update minimum Python version to `3.9` (#786)
- Update minimum Python version to `3.9`
ToDo:
- [x] Fix all errors
- [x] Update docker containers
- [x] Use `functools.cachedtools`
- [x] Test docker containers
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: mostafajahanifar <74412979+mostafajahanifar@users.noreply.github.com>
---
.github/workflows/docker-publish.yml | 12 +-
.github/workflows/mypy-type-check.yml | 2 +-
.github/workflows/pip-install.yml | 2 +-
.github/workflows/python-package.yml | 2 +-
README.md | 2 +-
benchmarks/annotation_store.ipynb | 5375 +++++++++--------
benchmarks/annotation_store_alloc.py | 3 +-
docker/{3.8 => 3.11}/Debian/Dockerfile | 2 +-
docker/3.11/Ubuntu/Dockerfile | 30 +
docker/3.12/Debian/Dockerfile | 14 +
docker/3.12/Ubuntu/Dockerfile | 30 +
docs/installation.rst | 2 +-
examples/full-pipelines/slide-graph.ipynb | 5 +-
pyproject.toml | 6 +-
requirements/requirements.conda.yml | 2 +-
requirements/requirements.dev.conda.yml | 2 +-
requirements/requirements.win64.conda.yml | 2 +-
requirements/requirements.win64.dev.conda.yml | 2 +-
setup.py | 4 +-
tests/test_annotation_stores.py | 11 +-
tests/test_app_bokeh.py | 14 +-
tests/test_docs.py | 5 +-
tests/test_dsl.py | 5 +-
tests/test_wsireader.py | 6 +-
tiatoolbox/__init__.py | 12 +-
tiatoolbox/annotation/storage.py | 5 +-
tiatoolbox/cli/visualize.py | 8 +-
tiatoolbox/data/__init__.py | 6 +-
tiatoolbox/models/dataset/dataset_abc.py | 6 +-
tiatoolbox/tools/pyramid.py | 4 +-
tiatoolbox/tools/stainextract.py | 26 +-
tiatoolbox/typing.py | 13 +-
tiatoolbox/wsicore/wsimeta.py | 4 +-
tiatoolbox/wsicore/wsireader.py | 7 +-
34 files changed, 2850 insertions(+), 2781 deletions(-)
rename docker/{3.8 => 3.11}/Debian/Dockerfile (91%)
create mode 100644 docker/3.11/Ubuntu/Dockerfile
create mode 100644 docker/3.12/Debian/Dockerfile
create mode 100644 docker/3.12/Ubuntu/Dockerfile
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 4f63c729e..4d486766b 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -15,8 +15,6 @@ jobs:
fail-fast: true
matrix:
include:
- - dockerfile: ./docker/3.8/Debian/Dockerfile
- mtag: py3.8-debian
- dockerfile: ./docker/3.9/Debian/Dockerfile
mtag: py3.9-debian
- dockerfile: ./docker/3.9/Ubuntu/Dockerfile
@@ -25,7 +23,15 @@ jobs:
mtag: py3.10-debian
- dockerfile: ./docker/3.10/Ubuntu/Dockerfile
mtag: py3.10-ubuntu
- - dockerfile: ./docker/3.10/Ubuntu/Dockerfile
+ - dockerfile: ./docker/3.11/Debian/Dockerfile
+ mtag: py3.11-debian
+ - dockerfile: ./docker/3.11/Ubuntu/Dockerfile
+ mtag: py3.11-ubuntu
+ - dockerfile: ./docker/3.12/Debian/Dockerfile
+ mtag: py3.12-debian
+ - dockerfile: ./docker/3.12/Ubuntu/Dockerfile
+ mtag: py3.12-ubuntu
+ - dockerfile: ./docker/3.12/Ubuntu/Dockerfile
mtag: latest
permissions:
contents: read
diff --git a/.github/workflows/mypy-type-check.yml b/.github/workflows/mypy-type-check.yml
index a22f339c5..1c026da9e 100644
--- a/.github/workflows/mypy-type-check.yml
+++ b/.github/workflows/mypy-type-check.yml
@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
- python-version: ["3.8", "3.9", "3.10", "3.11"]
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
steps:
diff --git a/.github/workflows/pip-install.yml b/.github/workflows/pip-install.yml
index abdb11527..ffa6961c9 100644
--- a/.github/workflows/pip-install.yml
+++ b/.github/workflows/pip-install.yml
@@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: true
matrix:
- python-version: ["3.8", "3.9", "3.10", "3.11"]
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
os: [ubuntu-22.04, windows-latest, macos-latest]
steps:
- name: Set up Python ${{ matrix.python-version }}
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 321316040..9df1550c6 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -17,7 +17,7 @@ jobs:
strategy:
fail-fast: true
matrix:
- python-version: ["3.8", "3.9", "3.10", "3.11"]
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v3
diff --git a/README.md b/README.md
index 0c5de616d..da8c04f06 100644
--- a/README.md
+++ b/README.md
@@ -115,7 +115,7 @@ Prepare a computer as a convenient platform for further development of the Pytho
5. Create virtual environment for TIAToolbox using
```sh
- $ conda create -n tiatoolbox-dev python=3.8 # select version of your choice
+ $ conda create -n tiatoolbox-dev python=3.9 # select version of your choice
$ conda activate tiatoolbox-dev
$ pip install -r requirements/requirements_dev.txt
```
diff --git a/benchmarks/annotation_store.ipynb b/benchmarks/annotation_store.ipynb
index 6c8b83d65..882ab251c 100644
--- a/benchmarks/annotation_store.ipynb
+++ b/benchmarks/annotation_store.ipynb
@@ -1,2703 +1,2704 @@
{
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "aqPkpRk-pT5q"
- },
- "source": [
- "# Benchmarking Annotation Storage\n",
- "\n",
- "Click to open in: \\[[GitHub](https://github.com/TissueImageAnalytics/tiatoolbox/tree/develop/benchmarks/annotation_store.ipynb)\\]\\[[Colab](https://colab.research.google.com/github/TissueImageAnalytics/tiatoolbox/blob/develop/benchmarks/annotation_store.ipynb)\\]\\[[Kaggle](https://kaggle.com/kernels/welcome?src=https://github.com/TissueImageAnalytics/tiatoolbox/blob/develop/benchmarks/annotation_store.ipynb)\\]\n",
- "\n"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "BS0G58BPpT5s"
- },
- "source": [
- "_In order to run this notebook on a Kaggle platform, 1) click the Kaggle URL 2) click on Settings on the right of the Kaggle screen, 3) log in to your Kaggle account, 4) tick \"Internet\" checkbox under Settings, to enable necessary downloads._\n",
- "\n",
- "**NOTE:** Some parts of this notebook require a lot of memory. Part 2 in particular may not run on memory constrained systems. The notebook will run well on an MacBook Air (M1, 2020) but will use a lot of swap. It may require >64GB of memory for second half to avoid using swap.\n",
- "\n"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "EjHQXjqrpT5s"
- },
- "source": [
- "## About This Notebook\n",
- "\n",
- "Managing annotation, either created by hand or from model output, is a\n",
- "common task in computational pathology. For a small number of\n",
- "annotations this may be trivial. However, for large numbers of\n",
- "annotations, it is often necessary to store the annotations in a more\n",
- "structured format such as a database. This is because finding a desired\n",
- "subset of annotations within a very large collection, for example over\n",
- "one million cell boundary polygons derived from running HoVerNet on a\n",
- "WSI, may be very slow if performed in a naive manner. In the toolbox, we\n",
- "implement two storage method to make handling annotations easier:\n",
- "`DictionaryStore` and `SQLiteStore`.\n",
- "\n",
- "### Storage Classes\n",
- "\n",
- "Both stores act as a key-value store where the key is the annotation ID\n",
- "(as a string) and the value is the annotation. This follows the Python\n",
- "[`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)\n",
- "interface meaning that the stores can be used in the same way as a\n",
- "regular Python dictionary (`dict`).\n",
- "\n",
- "The `DictionaryStore` is implemented internally using a Python\n",
- "dictionary. It is a realtively simple class, operating with all\n",
- "annotations in memory and using a simple scan method to search for\n",
- "annotations. This works very well for a small number of annotations. In\n",
- "contrast the `SQLiteStore` is implemented using a SQLite database\n",
- "(either in memory or on disk), it is a more complex class making use of\n",
- "an rtree index to efficiently spatially search for annotations. This is\n",
- "much more suited to a very large number of annotations. However, they\n",
- "both follow the same interface and can be used interchangeably for\n",
- "almost all methods (`SQLiteStore` has some additional methods).\n",
- "\n",
- "### Provided Functionality (Mini Tutorial)\n",
- "\n",
- "The storage classes provide a lot of functionality including. This\n",
- "includes all of the standard `MutableMapping` methods, as well as\n",
- "some additional ones for querying the collection of annotations.\n",
- "Below is a brief summary of the main functionality.\n",
- "\n",
- "#### Adding Annotations\n",
- "\n",
- "```python\n",
- "from tiatoolbox.annotation.storage import Annotation, DictionaryStore, SQliteStore\n",
- "from shapely.geometry import Polygon\n",
- "\n",
- "# Create a new store. If no path is given it is an in-memory store.\n",
- "store = DictionaryStore()\n",
- "\n",
- "# An annotation is a shapely geometry and a JSON serializable dictionary\n",
- "annotation = Annotation(Polygon.from_bounds(0, 0, 1, 1), {\"id\": \"1\"})\n",
- "\n",
- "# Add the annotation to the store in the same way as a dictionary\n",
- "store[\"foo\"] = annotation\n",
- "\n",
- "# Bulk append is also supported. This will be faster in some contexts\n",
- "# (e.g. for an SQLiteStore) than adding them one at a time.\n",
- "# Here we add 100 simple box annotations.\n",
- "# As we have not specified a set of keys to use, a new UUID is generated\n",
- "# for each. The respective generated keys are also returned.\n",
- "annotations = [\n",
- " Annotation(Polygon.from_bounds(n, n, n + 1, n + 1), {\"id\": n}) for n in range(100)\n",
- "]\n",
- "keys = store.append_many(annotations)\n",
- "```\n",
- "\n",
- "#### Removing Annotations\n",
- "\n",
- "```python\n",
- "# Remove an annotation by key\n",
- "del store[\"foo\"]\n",
- "\n",
- "# Bulk removal\n",
- "keys = [\"1234-5676....\", \"...\"] # etc.\n",
- "store.remove_many(keys)\n",
- "```\n",
- "\n",
- "#### Querying Within a Region\n",
- "\n",
- "```python\n",
- "# Find all annotations which intersect a polygon\n",
- "search_region = Polygon.from_bounds(0, 0, 10, 10)\n",
- "result = store.query(search_region)\n",
- "\n",
- "# Find all annotations which are contained within a polygon\n",
- "search_region = Polygon.from_bounds(0, 0, 10, 10)\n",
- "result = store.query(search_region, geometry_predicate=\"contains\")\n",
- "```\n",
- "\n",
- "#### Querying Using A Predicate Statement\n",
- "\n",
- "```python\n",
- "# 'props' is a provided shorthand to access the 'properties' dictionary\n",
- "results = store.query(where=\"propd['id'] == 1\")\n",
- "```\n",
- "\n",
- "#### Serializing and Deserializing\n",
- "\n",
- "```python\n",
- "# Serialize the store to a GeoJSON string\n",
- "json_string = store.to_geojson()\n",
- "\n",
- "# Serialize the store to a GeoJSON file\n",
- "store.to_geojson(\"boxes.geojson\")\n",
- "\n",
- "# Deserialize a GeoJSON string into a store (even of a different type)\n",
- "sqlitestore = SqliteStore.from_geojson(\"boxes.geojson\")\n",
- "\n",
- "# The above is an in-memory store. We can also now write this to disk\n",
- "# as an SQLite database.\n",
- "sqlitestore.dump(\"boxes.db\")\n",
- "```\n",
- "\n",
- "### Benchmarking\n",
- "\n",
- "Here we evaluate the storage efficient and data querying performance of\n",
- "the annotation store versus other common formats. We will evaluate some\n",
- "common situations and use cases including:\n",
- "\n",
- "- Disk I/O (tested with an SSD)\n",
- "- Querying the data for annotations within a box region\n",
- "- Querying the data for annotations within a polygon region\n",
- "- Querying the data with a predicate e.g. 'class=1'\n",
- "\n",
- "All saved output is from running this notebook on a 2020 M1 MacBook Air with 16GB RAM.\n",
- "\n"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "aov8ENq2pT5t"
- },
- "source": [
- "## Imports\n",
- "\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "UoMpbDXopT5t"
- },
- "outputs": [],
- "source": [
- "\"\"\"Import modules required to run the Jupyter notebook.\"\"\"\n",
- "\n",
- "from __future__ import annotations\n",
- "\n",
- "# Clear logger to use tiatoolbox.logger\n",
- "import logging\n",
- "\n",
- "if logging.getLogger().hasHandlers():\n",
- " logging.getLogger().handlers.clear()\n",
- "\n",
- "import copy\n",
- "import pickle\n",
- "import sys\n",
- "import tempfile\n",
- "import timeit\n",
- "import uuid\n",
- "from pathlib import Path\n",
- "from typing import TYPE_CHECKING, Any, Generator\n",
- "\n",
- "import numpy as np\n",
- "from IPython.display import display\n",
- "from matplotlib import patheffects\n",
- "from matplotlib import pyplot as plt\n",
- "from shapely import affinity\n",
- "from shapely.geometry import MultiPolygon, Point, Polygon\n",
- "from tqdm.auto import tqdm\n",
- "\n",
- "if TYPE_CHECKING:\n",
- " from numbers import Number\n",
- "\n",
- "sys.path.append(\"..\") # If running locally without pypi installed tiatoolbox\n",
- "\n",
- "from tiatoolbox import logger\n",
- "from tiatoolbox.annotation.storage import (\n",
- " Annotation,\n",
- " DictionaryStore,\n",
- " SQLiteStore,\n",
- ")\n",
- "\n",
- "plt.style.use(\"ggplot\")"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "nW-UyVQOpT5u"
- },
- "source": [
- "## Data Generation & Utility Functions\n",
- "\n",
- "Here we define some useful functions to generate some artificial data\n",
- "and visualise results.\n",
- "\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "N5xNW64UpT5v"
- },
- "outputs": [],
- "source": [
- "def cell_polygon(\n",
- " xy: tuple[Number, Number],\n",
- " n_points: int = 20,\n",
- " radius: Number = 8,\n",
- " noise: Number = 0.01,\n",
- " eccentricity: tuple[Number, Number] = (1, 3),\n",
- " direction: str = \"CCW\",\n",
- " seed: int = 0,\n",
- " *,\n",
- " repeat_first: bool = True,\n",
- ") -> Polygon:\n",
- " \"\"\"Generate a fake cell boundary polygon.\n",
- "\n",
- " Borrowed from tiatoolbox unit tests.\n",
- "\n",
- " Cell boundaries are generated an ellipsoids with randomised eccentricity,\n",
- " added noise, and a random rotation.\n",
- "\n",
- " Args:\n",
- " xy (tuple(int)): The x,y centre point to generate the cell boundary around.\n",
- " n_points (int): Number of points in the boundary. Defaults to 20.\n",
- " radius (float): Radius of the points from the centre. Defaults to 10.\n",
- " noise (float): Noise to add to the point locations. Defaults to 1.\n",
- " eccentricity (tuple(float)): Range of values (low, high) to use for\n",
- " randomised eccentricity. Defaults to (1, 3).\n",
- " repeat_first (bool): Enforce that the last point is equal to the first.\n",
- " direction (str): Ordering of the points. Defaults to \"CCW\". Valid options\n",
- " are: counter-clockwise \"CCW\", and clockwise \"CW\".\n",
- " seed: Seed for the random number generator. Defaults to 0.\n",
- "\n",
- " \"\"\"\n",
- " rand_state = np.random.default_rng().__getstate__()\n",
- " rng_seed = np.random.default_rng(seed)\n",
- "\n",
- " if repeat_first:\n",
- " n_points -= 1\n",
- "\n",
- " # Generate points about an ellipse with random eccentricity\n",
- " x, y = xy\n",
- " alpha = np.linspace(0, 2 * np.pi - (2 * np.pi / n_points), n_points)\n",
- " rx = radius * (rng_seed.random() + 0.5)\n",
- " ry = rng_seed.uniform(*eccentricity) * radius - 0.5 * rx\n",
- " x = rx * np.cos(alpha) + x + (rng_seed.random(n_points) - 0.5) * noise\n",
- " y = ry * np.sin(alpha) + y + (rng_seed.random(n_points) - 0.5) * noise\n",
- " boundary_coords = np.stack([x, y], axis=1).astype(int).tolist()\n",
- "\n",
- " # Copy first coordinate to the end if required\n",
- " if repeat_first:\n",
- " boundary_coords = [*boundary_coords, boundary_coords[0]]\n",
- "\n",
- " # Swap direction\n",
- " if direction.strip().lower() == \"cw\":\n",
- " boundary_coords = boundary_coords[::-1]\n",
- "\n",
- " polygon = Polygon(boundary_coords)\n",
- "\n",
- " # Add random rotation\n",
- " angle = rng_seed.random() * 360\n",
- " polygon = affinity.rotate(polygon, angle, origin=\"centroid\")\n",
- "\n",
- " # Restore the random state\n",
- " np.random.default_rng().__setstate__(rand_state)\n",
- "\n",
- " return polygon"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "jyQEBhNIpT5v"
- },
- "outputs": [],
- "source": [
- "def cell_grid(\n",
- " size: tuple[int, int] = (10, 10),\n",
- " spacing: Number = 25,\n",
- ") -> Generator[Polygon, None, None]:\n",
- " \"\"\"Generate a grid of cell boundaries.\"\"\"\n",
- " return (\n",
- " cell_polygon(xy=np.multiply(ij, spacing), repeat_first=False, seed=n)\n",
- " for n, ij in enumerate(np.ndindex(size))\n",
- " )"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "VVjSum_9pT5v"
- },
- "outputs": [],
- "source": [
- "def plot_results(\n",
- " experiments: list[list[Number]],\n",
- " title: str,\n",
- " capsize: int = 5,\n",
- " **kwargs: dict[str, Any],\n",
- ") -> None:\n",
- " \"\"\"Plot the results of a benchmark.\n",
- "\n",
- " Uses the min for the bar height (see See\n",
- " https://docs.python.org/2/library/timeit.html#timeit.Timer.repeat),\n",
- " and plots a min-max error bar.\n",
- "\n",
- " \"\"\"\n",
- " x = range(len(experiments))\n",
- " color = [f\"C{x_i}\" for x_i in x]\n",
- " plt.bar(\n",
- " x=x,\n",
- " height=[min(e) for e in experiments],\n",
- " color=color,\n",
- " yerr=[[0 for e in experiments], [max(e) - min(e) for e in experiments]],\n",
- " capsize=capsize,\n",
- " **kwargs,\n",
- " )\n",
- " for i, (runs, c) in enumerate(zip(experiments, color)):\n",
- " plt.text(\n",
- " i,\n",
- " min(runs),\n",
- " f\" {min(runs):.4f}s\",\n",
- " ha=\"left\",\n",
- " va=\"bottom\",\n",
- " color=c,\n",
- " zorder=10,\n",
- " fontweight=\"bold\",\n",
- " path_effects=[\n",
- " patheffects.withStroke(linewidth=2, foreground=\"w\"),\n",
- " ],\n",
- " )\n",
- " plt.title(title)\n",
- " plt.hlines(\n",
- " 0.5,\n",
- " -0.5,\n",
- " len(experiments) - 0.5,\n",
- " linestyles=\"dashed\",\n",
- " colors=\"black\",\n",
- " alpha=0.5,\n",
- " )\n",
- " plt.yscale(\"log\")\n",
- " plt.xlabel(\"Store Type\")\n",
- " plt.ylabel(\"Time (s)\")"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "tHEUErSmpT5w"
- },
- "source": [
- "## Display Some Generated Data\n",
- "\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "YUQmgohbpT5w",
- "outputId": "1a0cdee1-e32d-41e9-fb9d-26c5ee572880"
- },
- "outputs": [
+ "cells": [
{
- "data": {
- "image/svg+xml": "",
- "text/plain": [
- ""
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "aqPkpRk-pT5q"
+ },
+ "source": [
+ "# Benchmarking Annotation Storage\n",
+ "\n",
+ "Click to open in: \\[[GitHub](https://github.com/TissueImageAnalytics/tiatoolbox/tree/develop/benchmarks/annotation_store.ipynb)\\]\\[[Colab](https://colab.research.google.com/github/TissueImageAnalytics/tiatoolbox/blob/develop/benchmarks/annotation_store.ipynb)\\]\\[[Kaggle](https://kaggle.com/kernels/welcome?src=https://github.com/TissueImageAnalytics/tiatoolbox/blob/develop/benchmarks/annotation_store.ipynb)\\]\n",
+ "\n"
]
- },
- "metadata": {},
- "output_type": "display_data"
},
{
- "data": {
- "image/svg+xml": "",
- "text/plain": [
- ""
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "BS0G58BPpT5s"
+ },
+ "source": [
+ "_In order to run this notebook on a Kaggle platform, 1) click the Kaggle URL 2) click on Settings on the right of the Kaggle screen, 3) log in to your Kaggle account, 4) tick \"Internet\" checkbox under Settings, to enable necessary downloads._\n",
+ "\n",
+ "**NOTE:** Some parts of this notebook require a lot of memory. Part 2 in particular may not run on memory constrained systems. The notebook will run well on an MacBook Air (M1, 2020) but will use a lot of swap. It may require >64GB of memory for second half to avoid using swap.\n",
+ "\n"
]
- },
- "metadata": {},
- "output_type": "display_data"
},
{
- "data": {
- "image/svg+xml": "",
- "text/plain": [
- ""
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "EjHQXjqrpT5s"
+ },
+ "source": [
+ "## About This Notebook\n",
+ "\n",
+ "Managing annotation, either created by hand or from model output, is a\n",
+ "common task in computational pathology. For a small number of\n",
+ "annotations this may be trivial. However, for large numbers of\n",
+ "annotations, it is often necessary to store the annotations in a more\n",
+ "structured format such as a database. This is because finding a desired\n",
+ "subset of annotations within a very large collection, for example over\n",
+ "one million cell boundary polygons derived from running HoVerNet on a\n",
+ "WSI, may be very slow if performed in a naive manner. In the toolbox, we\n",
+ "implement two storage method to make handling annotations easier:\n",
+ "`DictionaryStore` and `SQLiteStore`.\n",
+ "\n",
+ "### Storage Classes\n",
+ "\n",
+ "Both stores act as a key-value store where the key is the annotation ID\n",
+ "(as a string) and the value is the annotation. This follows the Python\n",
+ "[`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)\n",
+ "interface meaning that the stores can be used in the same way as a\n",
+ "regular Python dictionary (`dict`).\n",
+ "\n",
+ "The `DictionaryStore` is implemented internally using a Python\n",
+ "dictionary. It is a realtively simple class, operating with all\n",
+ "annotations in memory and using a simple scan method to search for\n",
+ "annotations. This works very well for a small number of annotations. In\n",
+ "contrast the `SQLiteStore` is implemented using a SQLite database\n",
+ "(either in memory or on disk), it is a more complex class making use of\n",
+ "an rtree index to efficiently spatially search for annotations. This is\n",
+ "much more suited to a very large number of annotations. However, they\n",
+ "both follow the same interface and can be used interchangeably for\n",
+ "almost all methods (`SQLiteStore` has some additional methods).\n",
+ "\n",
+ "### Provided Functionality (Mini Tutorial)\n",
+ "\n",
+ "The storage classes provide a lot of functionality including. This\n",
+ "includes all of the standard `MutableMapping` methods, as well as\n",
+ "some additional ones for querying the collection of annotations.\n",
+ "Below is a brief summary of the main functionality.\n",
+ "\n",
+ "#### Adding Annotations\n",
+ "\n",
+ "```python\n",
+ "from tiatoolbox.annotation.storage import Annotation, DictionaryStore, SQliteStore\n",
+ "from shapely.geometry import Polygon\n",
+ "\n",
+ "# Create a new store. If no path is given it is an in-memory store.\n",
+ "store = DictionaryStore()\n",
+ "\n",
+ "# An annotation is a shapely geometry and a JSON serializable dictionary\n",
+ "annotation = Annotation(Polygon.from_bounds(0, 0, 1, 1), {\"id\": \"1\"})\n",
+ "\n",
+ "# Add the annotation to the store in the same way as a dictionary\n",
+ "store[\"foo\"] = annotation\n",
+ "\n",
+ "# Bulk append is also supported. This will be faster in some contexts\n",
+ "# (e.g. for an SQLiteStore) than adding them one at a time.\n",
+ "# Here we add 100 simple box annotations.\n",
+ "# As we have not specified a set of keys to use, a new UUID is generated\n",
+ "# for each. The respective generated keys are also returned.\n",
+ "annotations = [\n",
+ " Annotation(Polygon.from_bounds(n, n, n + 1, n + 1), {\"id\": n}) for n in range(100)\n",
+ "]\n",
+ "keys = store.append_many(annotations)\n",
+ "```\n",
+ "\n",
+ "#### Removing Annotations\n",
+ "\n",
+ "```python\n",
+ "# Remove an annotation by key\n",
+ "del store[\"foo\"]\n",
+ "\n",
+ "# Bulk removal\n",
+ "keys = [\"1234-5676....\", \"...\"] # etc.\n",
+ "store.remove_many(keys)\n",
+ "```\n",
+ "\n",
+ "#### Querying Within a Region\n",
+ "\n",
+ "```python\n",
+ "# Find all annotations which intersect a polygon\n",
+ "search_region = Polygon.from_bounds(0, 0, 10, 10)\n",
+ "result = store.query(search_region)\n",
+ "\n",
+ "# Find all annotations which are contained within a polygon\n",
+ "search_region = Polygon.from_bounds(0, 0, 10, 10)\n",
+ "result = store.query(search_region, geometry_predicate=\"contains\")\n",
+ "```\n",
+ "\n",
+ "#### Querying Using A Predicate Statement\n",
+ "\n",
+ "```python\n",
+ "# 'props' is a provided shorthand to access the 'properties' dictionary\n",
+ "results = store.query(where=\"propd['id'] == 1\")\n",
+ "```\n",
+ "\n",
+ "#### Serializing and Deserializing\n",
+ "\n",
+ "```python\n",
+ "# Serialize the store to a GeoJSON string\n",
+ "json_string = store.to_geojson()\n",
+ "\n",
+ "# Serialize the store to a GeoJSON file\n",
+ "store.to_geojson(\"boxes.geojson\")\n",
+ "\n",
+ "# Deserialize a GeoJSON string into a store (even of a different type)\n",
+ "sqlitestore = SqliteStore.from_geojson(\"boxes.geojson\")\n",
+ "\n",
+ "# The above is an in-memory store. We can also now write this to disk\n",
+ "# as an SQLite database.\n",
+ "sqlitestore.dump(\"boxes.db\")\n",
+ "```\n",
+ "\n",
+ "### Benchmarking\n",
+ "\n",
+ "Here we evaluate the storage efficient and data querying performance of\n",
+ "the annotation store versus other common formats. We will evaluate some\n",
+ "common situations and use cases including:\n",
+ "\n",
+ "- Disk I/O (tested with an SSD)\n",
+ "- Querying the data for annotations within a box region\n",
+ "- Querying the data for annotations within a polygon region\n",
+ "- Querying the data with a predicate e.g. 'class=1'\n",
+ "\n",
+ "All saved output is from running this notebook on a 2020 M1 MacBook Air with 16GB RAM.\n",
+ "\n"
]
- },
- "metadata": {},
- "output_type": "display_data"
},
{
- "data": {
- "image/svg+xml": "",
- "text/plain": [
- ""
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "aov8ENq2pT5t"
+ },
+ "source": [
+ "## Imports\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "UoMpbDXopT5t"
+ },
+ "outputs": [],
+ "source": [
+ "\"\"\"Import modules required to run the Jupyter notebook.\"\"\"\n",
+ "\n",
+ "from __future__ import annotations\n",
+ "\n",
+ "# Clear logger to use tiatoolbox.logger\n",
+ "import logging\n",
+ "\n",
+ "if logging.getLogger().hasHandlers():\n",
+ " logging.getLogger().handlers.clear()\n",
+ "\n",
+ "import copy\n",
+ "import pickle\n",
+ "import sys\n",
+ "import tempfile\n",
+ "import timeit\n",
+ "import uuid\n",
+ "from pathlib import Path\n",
+ "from typing import TYPE_CHECKING, Any\n",
+ "\n",
+ "import numpy as np\n",
+ "from IPython.display import display\n",
+ "from matplotlib import patheffects\n",
+ "from matplotlib import pyplot as plt\n",
+ "from shapely import affinity\n",
+ "from shapely.geometry import MultiPolygon, Point, Polygon\n",
+ "from tqdm.auto import tqdm\n",
+ "\n",
+ "if TYPE_CHECKING:\n",
+ " from collections.abc import Generator\n",
+ " from numbers import Number\n",
+ "\n",
+ "sys.path.append(\"..\") # If running locally without pypi installed tiatoolbox\n",
+ "\n",
+ "from tiatoolbox import logger\n",
+ "from tiatoolbox.annotation.storage import (\n",
+ " Annotation,\n",
+ " DictionaryStore,\n",
+ " SQLiteStore,\n",
+ ")\n",
+ "\n",
+ "plt.style.use(\"ggplot\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "nW-UyVQOpT5u"
+ },
+ "source": [
+ "## Data Generation & Utility Functions\n",
+ "\n",
+ "Here we define some useful functions to generate some artificial data\n",
+ "and visualise results.\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "N5xNW64UpT5v"
+ },
+ "outputs": [],
+ "source": [
+ "def cell_polygon(\n",
+ " xy: tuple[Number, Number],\n",
+ " n_points: int = 20,\n",
+ " radius: Number = 8,\n",
+ " noise: Number = 0.01,\n",
+ " eccentricity: tuple[Number, Number] = (1, 3),\n",
+ " direction: str = \"CCW\",\n",
+ " seed: int = 0,\n",
+ " *,\n",
+ " repeat_first: bool = True,\n",
+ ") -> Polygon:\n",
+ " \"\"\"Generate a fake cell boundary polygon.\n",
+ "\n",
+ " Borrowed from tiatoolbox unit tests.\n",
+ "\n",
+ " Cell boundaries are generated an ellipsoids with randomised eccentricity,\n",
+ " added noise, and a random rotation.\n",
+ "\n",
+ " Args:\n",
+ " xy (tuple(int)): The x,y centre point to generate the cell boundary around.\n",
+ " n_points (int): Number of points in the boundary. Defaults to 20.\n",
+ " radius (float): Radius of the points from the centre. Defaults to 10.\n",
+ " noise (float): Noise to add to the point locations. Defaults to 1.\n",
+ " eccentricity (tuple(float)): Range of values (low, high) to use for\n",
+ " randomised eccentricity. Defaults to (1, 3).\n",
+ " repeat_first (bool): Enforce that the last point is equal to the first.\n",
+ " direction (str): Ordering of the points. Defaults to \"CCW\". Valid options\n",
+ " are: counter-clockwise \"CCW\", and clockwise \"CW\".\n",
+ " seed: Seed for the random number generator. Defaults to 0.\n",
+ "\n",
+ " \"\"\"\n",
+ " rand_state = np.random.default_rng().__getstate__()\n",
+ " rng_seed = np.random.default_rng(seed)\n",
+ "\n",
+ " if repeat_first:\n",
+ " n_points -= 1\n",
+ "\n",
+ " # Generate points about an ellipse with random eccentricity\n",
+ " x, y = xy\n",
+ " alpha = np.linspace(0, 2 * np.pi - (2 * np.pi / n_points), n_points)\n",
+ " rx = radius * (rng_seed.random() + 0.5)\n",
+ " ry = rng_seed.uniform(*eccentricity) * radius - 0.5 * rx\n",
+ " x = rx * np.cos(alpha) + x + (rng_seed.random(n_points) - 0.5) * noise\n",
+ " y = ry * np.sin(alpha) + y + (rng_seed.random(n_points) - 0.5) * noise\n",
+ " boundary_coords = np.stack([x, y], axis=1).astype(int).tolist()\n",
+ "\n",
+ " # Copy first coordinate to the end if required\n",
+ " if repeat_first:\n",
+ " boundary_coords = [*boundary_coords, boundary_coords[0]]\n",
+ "\n",
+ " # Swap direction\n",
+ " if direction.strip().lower() == \"cw\":\n",
+ " boundary_coords = boundary_coords[::-1]\n",
+ "\n",
+ " polygon = Polygon(boundary_coords)\n",
+ "\n",
+ " # Add random rotation\n",
+ " angle = rng_seed.random() * 360\n",
+ " polygon = affinity.rotate(polygon, angle, origin=\"centroid\")\n",
+ "\n",
+ " # Restore the random state\n",
+ " np.random.default_rng().__setstate__(rand_state)\n",
+ "\n",
+ " return polygon"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "jyQEBhNIpT5v"
+ },
+ "outputs": [],
+ "source": [
+ "def cell_grid(\n",
+ " size: tuple[int, int] = (10, 10),\n",
+ " spacing: Number = 25,\n",
+ ") -> Generator[Polygon, None, None]:\n",
+ " \"\"\"Generate a grid of cell boundaries.\"\"\"\n",
+ " return (\n",
+ " cell_polygon(xy=np.multiply(ij, spacing), repeat_first=False, seed=n)\n",
+ " for n, ij in enumerate(np.ndindex(size))\n",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "VVjSum_9pT5v"
+ },
+ "outputs": [],
+ "source": [
+ "def plot_results(\n",
+ " experiments: list[list[Number]],\n",
+ " title: str,\n",
+ " capsize: int = 5,\n",
+ " **kwargs: dict[str, Any],\n",
+ ") -> None:\n",
+ " \"\"\"Plot the results of a benchmark.\n",
+ "\n",
+ " Uses the min for the bar height (see See\n",
+ " https://docs.python.org/2/library/timeit.html#timeit.Timer.repeat),\n",
+ " and plots a min-max error bar.\n",
+ "\n",
+ " \"\"\"\n",
+ " x = range(len(experiments))\n",
+ " color = [f\"C{x_i}\" for x_i in x]\n",
+ " plt.bar(\n",
+ " x=x,\n",
+ " height=[min(e) for e in experiments],\n",
+ " color=color,\n",
+ " yerr=[[0 for e in experiments], [max(e) - min(e) for e in experiments]],\n",
+ " capsize=capsize,\n",
+ " **kwargs,\n",
+ " )\n",
+ " for i, (runs, c) in enumerate(zip(experiments, color)):\n",
+ " plt.text(\n",
+ " i,\n",
+ " min(runs),\n",
+ " f\" {min(runs):.4f}s\",\n",
+ " ha=\"left\",\n",
+ " va=\"bottom\",\n",
+ " color=c,\n",
+ " zorder=10,\n",
+ " fontweight=\"bold\",\n",
+ " path_effects=[\n",
+ " patheffects.withStroke(linewidth=2, foreground=\"w\"),\n",
+ " ],\n",
+ " )\n",
+ " plt.title(title)\n",
+ " plt.hlines(\n",
+ " 0.5,\n",
+ " -0.5,\n",
+ " len(experiments) - 0.5,\n",
+ " linestyles=\"dashed\",\n",
+ " colors=\"black\",\n",
+ " alpha=0.5,\n",
+ " )\n",
+ " plt.yscale(\"log\")\n",
+ " plt.xlabel(\"Store Type\")\n",
+ " plt.ylabel(\"Time (s)\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "tHEUErSmpT5w"
+ },
+ "source": [
+ "## Display Some Generated Data\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "YUQmgohbpT5w",
+ "outputId": "1a0cdee1-e32d-41e9-fb9d-26c5ee572880"
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": "",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/svg+xml": "",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/svg+xml": "",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/svg+xml": "",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "for n in range(4):\n",
+ " display(cell_polygon(xy=(0, 0), n_points=20, repeat_first=False, seed=n))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "APUNL2PtpT5w"
+ },
+ "source": [
+ "### Randomised Cell Boundaries\n",
+ "\n",
+ "Here we create a function to generate grid of cells for testing. It uses a fixed seed for reproducibility.\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "SOpBKM7IpT5w"
+ },
+ "source": [
+ "### A Sample 5×5 Grid\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "2xA-oG4VpT5w",
+ "outputId": "caea51e4-8a27-4dd1-ed0d-c272b93d8bb7"
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": "",
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "MultiPolygon(polygons=list(cell_grid(size=(5, 5), spacing=35)))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "b6S8vzFipT5w"
+ },
+ "source": [
+ "# Part 1: Small Scale Benchmarking of Annotation Storage\n",
+ "\n",
+ "Using the already defined data generation functions (`cell_polygon` and\n",
+ "`cell_grid`), we create some simple artificial cell boundaries by\n",
+ "creating a circle of points, adding some noise, scaling to introduce\n",
+ "eccentricity, and then rotating. We use 20 points per cell, which is a\n",
+ "reasonably high value for cell annotation. However, this can be\n",
+ "adjusted.\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "UZMoLDvkpT5x"
+ },
+ "source": [
+ "## 1.1) Appending Annotations (In-Memory & Disk I/O)\n",
+ "\n",
+ "Here we test:\n",
+ "\n",
+ "1. A python dictionary based in-memory store (`DictionaryStore`)\n",
+ "1. An SQLite database based in-memory store (`SQLiteStore`)\n",
+ "\n",
+ "Both of these stores may operate in memory. The `SQLiteStore` may also\n",
+ "be backed by an on-disk file for datasets which are too large to fit in\n",
+ "memory. The `DictionaryStore` class can serialise/deserialise itself\n",
+ "to/from disk in a line delimited GeoJSON format (each line seperated\n",
+ "by `\\n` is a valid GeoJSON object)\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "DZBiw_EepT5x"
+ },
+ "outputs": [],
+ "source": [
+ "# Convert to annotations (a dataclass pairing a geometry and (optional)\n",
+ "# key-value properties)\n",
+ "# Run time: ~2s\n",
+ "annotations = [\n",
+ " Annotation(polygon) for polygon in cell_grid(size=(100, 100), spacing=35)\n",
+ "]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "LUVa03F2pT5x"
+ },
+ "source": [
+ "### 1.1.1) In Memory Append\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "7PzE7AhdpT5x",
+ "outputId": "974bb3d0-3290-4315-a6fc-3b7ca90072a6"
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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",
+ "text/plain": [
+ "