ahcore 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ahcore/__init__.py +27 -0
- ahcore/augmentations/__init__.py +26 -0
- ahcore/augmentations/adapter_augmentation.py +35 -0
- ahcore/augmentations/interfaces.py +25 -0
- ahcore/augmentations/tile_features.py +104 -0
- ahcore/augmentations/tile_mask_pair.py +255 -0
- ahcore/data/__init__.py +26 -0
- ahcore/data/adapters/__init__.py +33 -0
- ahcore/data/adapters/cached_tile_features.py +848 -0
- ahcore/data/adapters/patient_label.py +148 -0
- ahcore/data/adapters/rna_expression.py +80 -0
- ahcore/data/adapters/tile_mask_pair.py +50 -0
- ahcore/data/collate.py +107 -0
- ahcore/data/interfaces.py +85 -0
- ahcore/data/mm_dataset.py +382 -0
- ahcore/data/slide_view.py +173 -0
- ahcore/data/stores/__init__.py +24 -0
- ahcore/data/stores/base.py +66 -0
- ahcore/data/stores/per_file_h5.py +212 -0
- ahcore/data/tile_dataset.py +342 -0
- ahcore/data/tile_view.py +65 -0
- ahcore/feature_extractors/__init__.py +17 -0
- ahcore/feature_extractors/feature_extractor.py +40 -0
- ahcore/feature_extractors/from_pack.py +69 -0
- ahcore/feature_extractors/interfaces.py +39 -0
- ahcore/manifest/__init__.py +64 -0
- ahcore/manifest/description.py +87 -0
- ahcore/manifest/manager.py +659 -0
- ahcore/manifest/models.py +210 -0
- ahcore/metrics/__init__.py +20 -0
- ahcore/metrics/metrics.py +128 -0
- ahcore/mm_lit_module.py +155 -0
- ahcore/models/__init__.py +16 -0
- ahcore/models/base_jit_model.py +76 -0
- ahcore/models/classifiers/__init__.py +19 -0
- ahcore/models/classifiers/ab_mil.py +156 -0
- ahcore/models/classifiers/linear.py +49 -0
- ahcore/models/classifiers/mean_mil.py +127 -0
- ahcore/models/classifiers/mlp.py +79 -0
- ahcore/models/encoders/__init__.py +18 -0
- ahcore/models/encoders/abmil_encoder.py +156 -0
- ahcore/models/encoders/mean_encoder.py +106 -0
- ahcore/models/encoders/mlp_encoder.py +61 -0
- ahcore/models/fusion/__init__.py +16 -0
- ahcore/models/fusion/fusion_mil.py +192 -0
- ahcore/models/interfaces.py +94 -0
- ahcore/models/layers/__init__.py +17 -0
- ahcore/models/layers/attention.py +106 -0
- ahcore/models/layers/mlp.py +113 -0
- ahcore/models/segmentation/__init__.py +16 -0
- ahcore/models/segmentation/monai_wrapper.py +47 -0
- ahcore/py.typed +0 -0
- ahcore/readers.py +465 -0
- ahcore/tasks/__init__.py +22 -0
- ahcore/tasks/interfaces.py +90 -0
- ahcore/tasks/task.py +43 -0
- ahcore/tools/__init__.py +19 -0
- ahcore/tools/config.yaml +87 -0
- ahcore/tools/fomo_pack.py +213 -0
- ahcore/transforms/__init__.py +16 -0
- ahcore/transforms/tile.py +169 -0
- ahcore/utils/__init__.py +13 -0
- ahcore/utils/cache_keys.py +162 -0
- ahcore/utils/debug_utils.py +52 -0
- ahcore/utils/features_manifest.py +62 -0
- ahcore/utils/image.py +39 -0
- ahcore/utils/io.py +120 -0
- ahcore/utils/types.py +68 -0
- ahcore/writers.py +478 -0
- ahcore-0.1.0.dist-info/METADATA +118 -0
- ahcore-0.1.0.dist-info/RECORD +73 -0
- ahcore-0.1.0.dist-info/WHEEL +4 -0
- ahcore-0.1.0.dist-info/licenses/LICENSE +201 -0
ahcore/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Copyright 2025 Jonas Teuwen & Joren Brunekreef. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
"""ahcore: reusable building blocks for whole-slide-image deep learning pipelines.
|
|
15
|
+
|
|
16
|
+
A manifest-backed data catalog, pluggable adapters, a foundation-model feature store, MIL / encoder /
|
|
17
|
+
fusion and segmentation model heads, a Task-driven PyTorch-Lightning module, and Zarr tile readers /
|
|
18
|
+
writers. Import the pieces from their subpackages (e.g. ``ahcore.models``, ``ahcore.data``,
|
|
19
|
+
``ahcore.tasks``, ``ahcore.readers``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# Single source of truth for the version; pyproject.toml reads it via hatch's version hook.
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
import ahcore.writers as writers
|
|
26
|
+
|
|
27
|
+
__all__ = ["writers"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Copyright 2025 Joren Brunekreef. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
from .adapter_augmentation import AdapterAugmentation
|
|
15
|
+
from .interfaces import AdapterAugmentor
|
|
16
|
+
from .tile_features import SelectRandomTiles, TileFeaturesAugmentation
|
|
17
|
+
from .tile_mask_pair import HEDColorAugmentation, TileMaskPairAugmentation
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AdapterAugmentation",
|
|
21
|
+
"AdapterAugmentor",
|
|
22
|
+
"HEDColorAugmentation",
|
|
23
|
+
"SelectRandomTiles",
|
|
24
|
+
"TileFeaturesAugmentation",
|
|
25
|
+
"TileMaskPairAugmentation",
|
|
26
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Copyright 2025 Joren Brunekreef. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import torch.nn as nn
|
|
17
|
+
|
|
18
|
+
from ahcore.augmentations.interfaces import AdapterAugmentor
|
|
19
|
+
from ahcore.data.interfaces import AdapterBundle
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AdapterAugmentation(nn.Module):
|
|
23
|
+
"""Composes an ordered list of :class:`AdapterAugmentor` modules into one bundle-to-bundle transform.
|
|
24
|
+
|
|
25
|
+
``forward`` threads the ``AdapterBundle`` through each augmentor in turn and returns the result.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, augmentors: list[AdapterAugmentor]) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self._augmentors = nn.ModuleList(augmentors)
|
|
31
|
+
|
|
32
|
+
def forward(self, bundle: AdapterBundle) -> AdapterBundle:
|
|
33
|
+
for augmentor in self._augmentors:
|
|
34
|
+
bundle = augmentor(bundle)
|
|
35
|
+
return bundle
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright 2025 Joren Brunekreef. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import torch.nn as nn
|
|
17
|
+
|
|
18
|
+
from ahcore.data.interfaces import AdapterBundle
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AdapterAugmentor(nn.Module):
|
|
22
|
+
"""Interface for an augmentor that maps an :class:`AdapterBundle` to an augmented ``AdapterBundle``."""
|
|
23
|
+
|
|
24
|
+
def __call__(self, bundle: AdapterBundle) -> AdapterBundle:
|
|
25
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Copyright 2025 Joren Brunekreef. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
import torch
|
|
20
|
+
import torch.nn as nn
|
|
21
|
+
|
|
22
|
+
from ahcore.data.interfaces import AdapterBundle
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TileFeaturesAugmentation(nn.Module):
|
|
26
|
+
"""Runs an ordered list of bundle-to-bundle transforms over a tile-features :class:`AdapterBundle`.
|
|
27
|
+
|
|
28
|
+
Each transform maps an ``AdapterBundle`` to an ``AdapterBundle``; ``forward`` applies them in order.
|
|
29
|
+
Use :meth:`for_classification` for the default tile-subsampling pipeline.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, transforms: list[Callable[[AdapterBundle], AdapterBundle]]) -> None:
|
|
33
|
+
super().__init__()
|
|
34
|
+
self._transforms = transforms
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def for_classification(
|
|
38
|
+
cls, num_tiles: int = 1000, seed: int | None = None, per_sample: bool = False, meta_key: str = "slide_id"
|
|
39
|
+
) -> TileFeaturesAugmentation:
|
|
40
|
+
transforms: list[Callable[[AdapterBundle], AdapterBundle]] = [
|
|
41
|
+
SelectRandomTiles(num_tiles=num_tiles, seed=seed, per_sample=per_sample, meta_key=meta_key),
|
|
42
|
+
]
|
|
43
|
+
return TileFeaturesAugmentation(transforms)
|
|
44
|
+
|
|
45
|
+
def forward(self, data: AdapterBundle) -> AdapterBundle:
|
|
46
|
+
for transform in self._transforms:
|
|
47
|
+
data = transform(data)
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SelectRandomTiles(nn.Module):
|
|
52
|
+
"""Randomly subsample a fixed number of tiles from a bundle, keeping all per-tile tensors aligned.
|
|
53
|
+
|
|
54
|
+
Samples ``num_tiles`` rows without replacement (with replacement if the bag is smaller) and applies
|
|
55
|
+
the same indices to every per-tile tensor in ``bundle.data`` so features/coords stay row-aligned.
|
|
56
|
+
With ``seed`` set the choice is deterministic; ``per_sample=True`` derives the seed from
|
|
57
|
+
``bundle.meta[meta_key]`` so each slide gets its own reproducible selection.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self, num_tiles: int, seed: int | None = None, per_sample: bool = False, meta_key: str = "slide_id"
|
|
62
|
+
) -> None:
|
|
63
|
+
super().__init__()
|
|
64
|
+
self._num_tiles = num_tiles
|
|
65
|
+
self._seed = seed
|
|
66
|
+
self._per_sample = per_sample
|
|
67
|
+
self._meta_key = meta_key
|
|
68
|
+
|
|
69
|
+
def _make_generator(self, bundle: AdapterBundle, device: torch.device | str) -> torch.Generator | None:
|
|
70
|
+
if self._seed is None:
|
|
71
|
+
return None
|
|
72
|
+
derived_seed = self._seed
|
|
73
|
+
if (
|
|
74
|
+
self._per_sample
|
|
75
|
+
and hasattr(bundle, "meta")
|
|
76
|
+
and isinstance(bundle.meta, dict)
|
|
77
|
+
and self._meta_key in bundle.meta
|
|
78
|
+
):
|
|
79
|
+
key = str(bundle.meta[self._meta_key])
|
|
80
|
+
digest = hashlib.sha256(f"{self._seed}:{key}".encode("utf-8")).digest()
|
|
81
|
+
derived_seed = int.from_bytes(digest[:8], "little", signed=False)
|
|
82
|
+
gen = torch.Generator(device=device)
|
|
83
|
+
gen.manual_seed(derived_seed)
|
|
84
|
+
return gen
|
|
85
|
+
|
|
86
|
+
def forward(self, bundle: AdapterBundle) -> AdapterBundle:
|
|
87
|
+
features = bundle.data["features"]
|
|
88
|
+
num_features = features.shape[0]
|
|
89
|
+
|
|
90
|
+
gen = self._make_generator(bundle, features.device)
|
|
91
|
+
|
|
92
|
+
if num_features < self._num_tiles:
|
|
93
|
+
# If there are fewer features than num_tiles, we sample with replacement.
|
|
94
|
+
indices = torch.randint(0, num_features, (self._num_tiles,), device=features.device, generator=gen)
|
|
95
|
+
else:
|
|
96
|
+
# Otherwise, we sample without replacement.
|
|
97
|
+
indices = torch.randperm(num_features, device=features.device, generator=gen)[: self._num_tiles]
|
|
98
|
+
|
|
99
|
+
# Subsample every per-tile tensor by the same indices so features / coords / (future)
|
|
100
|
+
# patch_tokens stay row-aligned — no per-key hardcoding.
|
|
101
|
+
for key, value in bundle.data.items():
|
|
102
|
+
if isinstance(value, torch.Tensor) and value.shape[0] == num_features:
|
|
103
|
+
bundle.data[key] = value[indices]
|
|
104
|
+
return bundle
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Copyright 2025 Joren Brunekreef. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Optional, cast
|
|
17
|
+
|
|
18
|
+
import kornia.augmentation as K
|
|
19
|
+
import torch
|
|
20
|
+
import torch.nn as nn
|
|
21
|
+
from kornia.augmentation import random_generator as rg
|
|
22
|
+
from kornia.constants import DataKey, Resample
|
|
23
|
+
from omegaconf import ListConfig
|
|
24
|
+
|
|
25
|
+
from ahcore.data.interfaces import AdapterBundle
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TileMaskPairAugmentation(nn.Module):
|
|
29
|
+
"""Kornia augmentation for a segmentation (image, mask, ignore_mask) triple in an ``AdapterBundle``.
|
|
30
|
+
|
|
31
|
+
Applies intensity augmentations to the image only, then geometric augmentations jointly to image
|
|
32
|
+
and masks (nearest-neighbour resampling on the masks), and finally centre-crops all three to
|
|
33
|
+
``tile_size``. Use :meth:`for_segmentation` for the default augmentation set.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
tile_size: tuple[int, int],
|
|
39
|
+
intensity_augmentations: list[K.IntensityAugmentationBase2D],
|
|
40
|
+
geometric_augmentations: list[K.GeometricAugmentationBase2D],
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
tile_size = (int(tile_size[0]), int(tile_size[1])) # Handle omegaconf ListConfig
|
|
44
|
+
self._intensity_augmentations = K.AugmentationSequential(
|
|
45
|
+
*intensity_augmentations,
|
|
46
|
+
data_keys=[DataKey.INPUT],
|
|
47
|
+
same_on_batch=False,
|
|
48
|
+
random_apply=1,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
self._geometric_augmentations = K.AugmentationSequential(
|
|
52
|
+
*geometric_augmentations,
|
|
53
|
+
data_keys=[DataKey.INPUT, DataKey.MASK, DataKey.MASK],
|
|
54
|
+
same_on_batch=False,
|
|
55
|
+
extra_args={DataKey.MASK: dict(resample=Resample.NEAREST, align_corners=True)},
|
|
56
|
+
random_apply=1,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self._final_transforms = K.AugmentationSequential(
|
|
60
|
+
K.CenterCrop(size=tile_size),
|
|
61
|
+
data_keys=[DataKey.INPUT, DataKey.MASK, DataKey.MASK],
|
|
62
|
+
same_on_batch=False,
|
|
63
|
+
extra_args={DataKey.MASK: dict(resample=Resample.NEAREST, align_corners=True)},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def for_segmentation(
|
|
68
|
+
cls,
|
|
69
|
+
tile_size: tuple[int, int] = (1024, 1024),
|
|
70
|
+
) -> TileMaskPairAugmentation:
|
|
71
|
+
intensity_augmentations = [
|
|
72
|
+
# HEDColorAugmentation(p=0.5, scale_sigma=0.2, bias_sigma=0.2),
|
|
73
|
+
K.ColorJitter(p=0.5, brightness=0.05, contrast=0.05, saturation=0.05, hue=0.05),
|
|
74
|
+
K.RandomGaussianBlur(p=0.5, kernel_size=(9, 9), sigma=(0.1, 1.0)),
|
|
75
|
+
K.RandomSharpness(p=0.5, sharpness=10),
|
|
76
|
+
]
|
|
77
|
+
geometric_augmentations = [
|
|
78
|
+
K.RandomHorizontalFlip(p=0.5),
|
|
79
|
+
K.RandomVerticalFlip(p=0.5),
|
|
80
|
+
K.RandomPerspective(p=0.5, distortion_scale=0.5),
|
|
81
|
+
K.RandomAffine(p=0.5, degrees=(0, 90)),
|
|
82
|
+
K.RandomAffine(p=0.5, degrees=0, scale=(0.75, 1.0)),
|
|
83
|
+
K.RandomAffine(p=0.5, degrees=0, translate=(0, 0.05)),
|
|
84
|
+
K.RandomAffine(p=0.5, degrees=0, shear=(0, 5)),
|
|
85
|
+
]
|
|
86
|
+
return TileMaskPairAugmentation(tile_size, intensity_augmentations, geometric_augmentations)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _to_kornia(mask: torch.Tensor) -> torch.Tensor:
|
|
90
|
+
"""Present a mask to kornia as a float [B, C, H, W]: grid_sample needs floats, and a bool input
|
|
91
|
+
would otherwise demote the co-warped masks. A 2D [H, W] label map gains explicit batch/channel dims."""
|
|
92
|
+
while mask.ndim < 4:
|
|
93
|
+
mask = mask.unsqueeze(0)
|
|
94
|
+
return mask.float()
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _from_kornia(mask: torch.Tensor, ndim: int, dtype: torch.dtype) -> torch.Tensor:
|
|
98
|
+
"""Undo :meth:`_to_kornia`: drop the added batch (and, for a 2D input, channel) dims and restore
|
|
99
|
+
the original dtype. NEAREST resampling keeps values exact, so casting back is lossless."""
|
|
100
|
+
mask = mask.squeeze(0) # drop batch
|
|
101
|
+
if ndim == 2:
|
|
102
|
+
mask = mask.squeeze(0) # drop the channel added for a 2D label map
|
|
103
|
+
return mask.to(dtype)
|
|
104
|
+
|
|
105
|
+
def forward(self, bundle: AdapterBundle) -> AdapterBundle:
|
|
106
|
+
image = bundle.data["image"].unsqueeze(0) # kornia augmentations expect a batch dimension
|
|
107
|
+
mask, ignore_mask = bundle.data["mask"], bundle.data["ignore_mask"]
|
|
108
|
+
|
|
109
|
+
mask_ndim, mask_dtype = mask.ndim, mask.dtype
|
|
110
|
+
ignore_ndim, ignore_dtype = ignore_mask.ndim, ignore_mask.dtype
|
|
111
|
+
mask = self._to_kornia(mask)
|
|
112
|
+
ignore_mask = self._to_kornia(ignore_mask)
|
|
113
|
+
|
|
114
|
+
image = self._intensity_augmentations(image)
|
|
115
|
+
image, mask, ignore_mask = self._geometric_augmentations(image, mask, ignore_mask)
|
|
116
|
+
image, mask, ignore_mask = self._final_transforms(image, mask, ignore_mask)
|
|
117
|
+
|
|
118
|
+
bundle.data["image"] = image.squeeze(0)
|
|
119
|
+
bundle.data["mask"] = self._from_kornia(mask, mask_ndim, mask_dtype)
|
|
120
|
+
bundle.data["ignore_mask"] = self._from_kornia(ignore_mask, ignore_ndim, ignore_dtype)
|
|
121
|
+
return bundle
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class HEDColorAugmentation(K.IntensityAugmentationBase2D):
|
|
125
|
+
"""
|
|
126
|
+
A torch implementation of the color stain augmentation algorithm on the
|
|
127
|
+
deconvolved Hemaetoxylin-Eosin-DAB (HED) channels of an image as described
|
|
128
|
+
by Tellez et al. (2018) in Appendix A & B here: https://arxiv.org/pdf/1808.05896.pdf.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
# Normalized OD matrix from Ruifrok et al. (2001)
|
|
132
|
+
HED_REFERENCE = torch.Tensor([[0.65, 0.70, 0.29], [0.07, 0.99, 0.11], [0.27, 0.57, 0.78]])
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
scale_sigma: float | list[float] | ListConfig,
|
|
137
|
+
bias_sigma: float | list[float] | ListConfig,
|
|
138
|
+
epsilon: float = 1e-6,
|
|
139
|
+
clamp_output_range: Optional[tuple[float, float]] = (0, 1),
|
|
140
|
+
p: float = 0.5,
|
|
141
|
+
p_batch: float = 1.0,
|
|
142
|
+
same_on_batch: bool = False,
|
|
143
|
+
keepdim: bool = False,
|
|
144
|
+
**kwargs: Any,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Apply a color stain augmentation in the Hemaetoxylin-Eosin-DAB (HED) color space based on [1].
|
|
148
|
+
The fixed normalized OD matrix values are based on [2].
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
scale_sigma: float, ListConfig or list of floats
|
|
153
|
+
For each channel in the HED space a random scaling factor is drawn from alpha_i ~ U(1-sigma_i,1+sigma_i).
|
|
154
|
+
bias_sigma: float, ListConfig or list of floats
|
|
155
|
+
For each channel in the HED space a random bias is added drawn from beta_i ~ U(-sigma_i,sigma_i).
|
|
156
|
+
epsilon: float
|
|
157
|
+
Small positive bias to avoid numerical errors
|
|
158
|
+
clamp_output_range: tuple of floats or None
|
|
159
|
+
Clamp output in range after augmenting the input. Conventionally a range of [0, 1] is used, but this will
|
|
160
|
+
discard some color information. `scale_sigma` and `bias_sigma` should be chosen carefully to prevent this.
|
|
161
|
+
|
|
162
|
+
NOTE: To reproduce augmentations as shown in [3], a larger positive bias of `epsilon=2` in combination with
|
|
163
|
+
`clamp_output_range=(0, 1)` should be used. Then, use `scale_sigma=bias_sigma=0.05` for `HED-light` and
|
|
164
|
+
`scale_sigma=bias_sigma=0.2` for `HED-strong`.
|
|
165
|
+
When using the default `epsilon=1e-6` and `clamp_output_range=(0, 1)`, this is not equal but comparable to
|
|
166
|
+
`scale_sigma=bias_sigma=0.15` for `HED-light` and `scale_sigma=bias_sigma=0.8` for `HED-strong`?
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
References
|
|
170
|
+
----------
|
|
171
|
+
[1] Tellez, David, et al. "Whole-slide mitosis detection in H&E breast histology using PHH3 as
|
|
172
|
+
a reference to train distilled stain-invariant convolutional networks."
|
|
173
|
+
IEEE transactions on medical imaging 37.9 (2018): 2126-2136.
|
|
174
|
+
[2] Ruifrok AC, Johnston DA. Quantification of histochemical staining by color deconvolution.
|
|
175
|
+
Anal Quant Cytol Histol. 2001 Aug;23(4):291-9. PMID: 11531144.
|
|
176
|
+
[3] Tellez, David, et al. "Quantifying the effects of data augmentation and stain color normalization
|
|
177
|
+
in convolutional neural networks for computational pathology."
|
|
178
|
+
Medical image analysis 58 (2019): 101544.
|
|
179
|
+
"""
|
|
180
|
+
super().__init__(p=p, p_batch=p_batch, same_on_batch=same_on_batch, keepdim=keepdim)
|
|
181
|
+
|
|
182
|
+
if isinstance(scale_sigma, ListConfig):
|
|
183
|
+
scale_sigma = list(scale_sigma)
|
|
184
|
+
if isinstance(bias_sigma, ListConfig):
|
|
185
|
+
bias_sigma = list(bias_sigma)
|
|
186
|
+
|
|
187
|
+
# Accept int too: an int is a valid `float` argument, and a whole-number config value (e.g. YAML
|
|
188
|
+
# `0` or `1`) arrives as int, so a scalar must broadcast whether it comes in as int or float.
|
|
189
|
+
if isinstance(scale_sigma, (int, float)):
|
|
190
|
+
scale_sigma = [scale_sigma] * 3
|
|
191
|
+
|
|
192
|
+
if isinstance(bias_sigma, (int, float)):
|
|
193
|
+
bias_sigma = [bias_sigma] * 3
|
|
194
|
+
|
|
195
|
+
if len(scale_sigma) not in [1, 3] or len(bias_sigma) not in [1, 3]:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"scale_sigma and bias_sigma should have either 1 or 3 values, "
|
|
198
|
+
f"got {scale_sigma} and {bias_sigma} instead."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
assert isinstance(scale_sigma, list)
|
|
202
|
+
assert isinstance(bias_sigma, list)
|
|
203
|
+
|
|
204
|
+
_scale_sigma = torch.Tensor(scale_sigma).float()
|
|
205
|
+
_bias_sigma = torch.Tensor(bias_sigma).float()
|
|
206
|
+
|
|
207
|
+
scale_factor = torch.stack([1.0 - _scale_sigma, 1.0 + _scale_sigma], dim=0)
|
|
208
|
+
bias_factor = torch.stack([-_bias_sigma, _bias_sigma], dim=0) # pylint:disable=E1130
|
|
209
|
+
|
|
210
|
+
self._param_generator = rg.PlainUniformGenerator(
|
|
211
|
+
(scale_factor, "scale", None, None), (bias_factor, "bias", None, None)
|
|
212
|
+
)
|
|
213
|
+
self.flags = {
|
|
214
|
+
"epsilon": torch.tensor([epsilon]),
|
|
215
|
+
"M": self.HED_REFERENCE,
|
|
216
|
+
"M_inv": torch.linalg.inv(self.HED_REFERENCE), # pylint:disable=E1102
|
|
217
|
+
"clamp_output_range": clamp_output_range,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
def apply_transform(
|
|
221
|
+
self,
|
|
222
|
+
input: torch.Tensor,
|
|
223
|
+
params: dict[str, torch.Tensor],
|
|
224
|
+
flags: dict[str, Any],
|
|
225
|
+
transform: Optional[torch.Tensor] = None,
|
|
226
|
+
**kwargs: Any,
|
|
227
|
+
) -> torch.Tensor:
|
|
228
|
+
"""
|
|
229
|
+
Apply HED color augmentation on an input tensor.
|
|
230
|
+
"""
|
|
231
|
+
assert flags, "Flags should be provided"
|
|
232
|
+
assert params, "Params should be provided"
|
|
233
|
+
|
|
234
|
+
epsilon = flags["epsilon"].to(input)
|
|
235
|
+
reference_matrix = flags["M"].to(input)
|
|
236
|
+
reference_matrix_inv = flags["M_inv"].to(input)
|
|
237
|
+
alpha = params["scale"][:, None, None, :].to(input)
|
|
238
|
+
beta = params["bias"][:, None, None, :].to(input)
|
|
239
|
+
|
|
240
|
+
rgb_tensor = input.permute(0, 2, 3, 1)
|
|
241
|
+
optical_density = -torch.log(rgb_tensor + epsilon)
|
|
242
|
+
# Mypy doesn't understand this is a tensor.
|
|
243
|
+
hed_tensor = cast(torch.Tensor, optical_density @ reference_matrix_inv)
|
|
244
|
+
|
|
245
|
+
augmented_hed_tensor = alpha * hed_tensor + beta
|
|
246
|
+
# Same problem that mypy doesn't understand
|
|
247
|
+
augmented_rgb_tensor = torch.exp(-augmented_hed_tensor @ reference_matrix) - epsilon
|
|
248
|
+
augmented_sample = augmented_rgb_tensor.permute(0, 3, 1, 2)
|
|
249
|
+
|
|
250
|
+
# The linter seems to require this
|
|
251
|
+
assert isinstance(augmented_sample, torch.Tensor)
|
|
252
|
+
|
|
253
|
+
if flags["clamp_output_range"] is not None:
|
|
254
|
+
augmented_sample = torch.clamp(augmented_sample, *flags["clamp_output_range"])
|
|
255
|
+
return augmented_sample
|
ahcore/data/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Copyright 2025 Joren Brunekreef. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
from .interfaces import (
|
|
15
|
+
AdapterBundle,
|
|
16
|
+
ShapePolicy,
|
|
17
|
+
SlideView,
|
|
18
|
+
TileView,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AdapterBundle",
|
|
23
|
+
"ShapePolicy",
|
|
24
|
+
"SlideView",
|
|
25
|
+
"TileView",
|
|
26
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright 2025 Joren Brunekreef. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from ahcore.data import SlideView, TileView
|
|
17
|
+
from ahcore.data.interfaces import AdapterBundle
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SlideAdapter(Protocol):
|
|
21
|
+
"""Protocol for an adapter that produces a representation bundle for a slide."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None: ...
|
|
24
|
+
|
|
25
|
+
def __call__(self, slide_view: SlideView) -> AdapterBundle: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TileAdapter(Protocol):
|
|
29
|
+
"""Protocol for an adapter that produces a representation bundle for a tile."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None: ...
|
|
32
|
+
|
|
33
|
+
def __call__(self, tile_view: TileView) -> AdapterBundle: ...
|