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.
Files changed (73) hide show
  1. ahcore/__init__.py +27 -0
  2. ahcore/augmentations/__init__.py +26 -0
  3. ahcore/augmentations/adapter_augmentation.py +35 -0
  4. ahcore/augmentations/interfaces.py +25 -0
  5. ahcore/augmentations/tile_features.py +104 -0
  6. ahcore/augmentations/tile_mask_pair.py +255 -0
  7. ahcore/data/__init__.py +26 -0
  8. ahcore/data/adapters/__init__.py +33 -0
  9. ahcore/data/adapters/cached_tile_features.py +848 -0
  10. ahcore/data/adapters/patient_label.py +148 -0
  11. ahcore/data/adapters/rna_expression.py +80 -0
  12. ahcore/data/adapters/tile_mask_pair.py +50 -0
  13. ahcore/data/collate.py +107 -0
  14. ahcore/data/interfaces.py +85 -0
  15. ahcore/data/mm_dataset.py +382 -0
  16. ahcore/data/slide_view.py +173 -0
  17. ahcore/data/stores/__init__.py +24 -0
  18. ahcore/data/stores/base.py +66 -0
  19. ahcore/data/stores/per_file_h5.py +212 -0
  20. ahcore/data/tile_dataset.py +342 -0
  21. ahcore/data/tile_view.py +65 -0
  22. ahcore/feature_extractors/__init__.py +17 -0
  23. ahcore/feature_extractors/feature_extractor.py +40 -0
  24. ahcore/feature_extractors/from_pack.py +69 -0
  25. ahcore/feature_extractors/interfaces.py +39 -0
  26. ahcore/manifest/__init__.py +64 -0
  27. ahcore/manifest/description.py +87 -0
  28. ahcore/manifest/manager.py +659 -0
  29. ahcore/manifest/models.py +210 -0
  30. ahcore/metrics/__init__.py +20 -0
  31. ahcore/metrics/metrics.py +128 -0
  32. ahcore/mm_lit_module.py +155 -0
  33. ahcore/models/__init__.py +16 -0
  34. ahcore/models/base_jit_model.py +76 -0
  35. ahcore/models/classifiers/__init__.py +19 -0
  36. ahcore/models/classifiers/ab_mil.py +156 -0
  37. ahcore/models/classifiers/linear.py +49 -0
  38. ahcore/models/classifiers/mean_mil.py +127 -0
  39. ahcore/models/classifiers/mlp.py +79 -0
  40. ahcore/models/encoders/__init__.py +18 -0
  41. ahcore/models/encoders/abmil_encoder.py +156 -0
  42. ahcore/models/encoders/mean_encoder.py +106 -0
  43. ahcore/models/encoders/mlp_encoder.py +61 -0
  44. ahcore/models/fusion/__init__.py +16 -0
  45. ahcore/models/fusion/fusion_mil.py +192 -0
  46. ahcore/models/interfaces.py +94 -0
  47. ahcore/models/layers/__init__.py +17 -0
  48. ahcore/models/layers/attention.py +106 -0
  49. ahcore/models/layers/mlp.py +113 -0
  50. ahcore/models/segmentation/__init__.py +16 -0
  51. ahcore/models/segmentation/monai_wrapper.py +47 -0
  52. ahcore/py.typed +0 -0
  53. ahcore/readers.py +465 -0
  54. ahcore/tasks/__init__.py +22 -0
  55. ahcore/tasks/interfaces.py +90 -0
  56. ahcore/tasks/task.py +43 -0
  57. ahcore/tools/__init__.py +19 -0
  58. ahcore/tools/config.yaml +87 -0
  59. ahcore/tools/fomo_pack.py +213 -0
  60. ahcore/transforms/__init__.py +16 -0
  61. ahcore/transforms/tile.py +169 -0
  62. ahcore/utils/__init__.py +13 -0
  63. ahcore/utils/cache_keys.py +162 -0
  64. ahcore/utils/debug_utils.py +52 -0
  65. ahcore/utils/features_manifest.py +62 -0
  66. ahcore/utils/image.py +39 -0
  67. ahcore/utils/io.py +120 -0
  68. ahcore/utils/types.py +68 -0
  69. ahcore/writers.py +478 -0
  70. ahcore-0.1.0.dist-info/METADATA +118 -0
  71. ahcore-0.1.0.dist-info/RECORD +73 -0
  72. ahcore-0.1.0.dist-info/WHEEL +4 -0
  73. 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
@@ -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: ...