pyplatypus 0.2.0a1__tar.gz

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 (67) hide show
  1. pyplatypus-0.2.0a1/LICENSE +22 -0
  2. pyplatypus-0.2.0a1/MANIFEST.in +11 -0
  3. pyplatypus-0.2.0a1/PKG-INFO +117 -0
  4. pyplatypus-0.2.0a1/README.md +75 -0
  5. pyplatypus-0.2.0a1/examples/data_science_bowl.yaml +89 -0
  6. pyplatypus-0.2.0a1/pyplatypus/__init__.py +23 -0
  7. pyplatypus-0.2.0a1/pyplatypus/data/__init__.py +16 -0
  8. pyplatypus-0.2.0a1/pyplatypus/data/augmentation.py +77 -0
  9. pyplatypus-0.2.0a1/pyplatypus/data/dataset.py +116 -0
  10. pyplatypus-0.2.0a1/pyplatypus/data/images.py +121 -0
  11. pyplatypus-0.2.0a1/pyplatypus/data/masks.py +85 -0
  12. pyplatypus-0.2.0a1/pyplatypus/data/paths.py +152 -0
  13. pyplatypus-0.2.0a1/pyplatypus/engine.py +194 -0
  14. pyplatypus-0.2.0a1/pyplatypus/errors.py +94 -0
  15. pyplatypus-0.2.0a1/pyplatypus/models/__init__.py +8 -0
  16. pyplatypus-0.2.0a1/pyplatypus/models/encoders.py +68 -0
  17. pyplatypus-0.2.0a1/pyplatypus/models/layers.py +149 -0
  18. pyplatypus-0.2.0a1/pyplatypus/models/unet.py +148 -0
  19. pyplatypus-0.2.0a1/pyplatypus/objectives/__init__.py +6 -0
  20. pyplatypus-0.2.0a1/pyplatypus/objectives/functional.py +157 -0
  21. pyplatypus-0.2.0a1/pyplatypus/objectives/losses.py +151 -0
  22. pyplatypus-0.2.0a1/pyplatypus/objectives/metrics.py +85 -0
  23. pyplatypus-0.2.0a1/pyplatypus/spec/__init__.py +32 -0
  24. pyplatypus-0.2.0a1/pyplatypus/spec/common.py +57 -0
  25. pyplatypus-0.2.0a1/pyplatypus/spec/components.py +311 -0
  26. pyplatypus-0.2.0a1/pyplatypus/spec/data.py +69 -0
  27. pyplatypus-0.2.0a1/pyplatypus/spec/loader.py +54 -0
  28. pyplatypus-0.2.0a1/pyplatypus/spec/models.py +177 -0
  29. pyplatypus-0.2.0a1/pyplatypus/spec/schema.py +35 -0
  30. pyplatypus-0.2.0a1/pyplatypus/spec/spec.py +71 -0
  31. pyplatypus-0.2.0a1/pyplatypus/training/__init__.py +14 -0
  32. pyplatypus-0.2.0a1/pyplatypus/training/callbacks.py +209 -0
  33. pyplatypus-0.2.0a1/pyplatypus/training/optimizers.py +54 -0
  34. pyplatypus-0.2.0a1/pyplatypus/training/torch_data.py +48 -0
  35. pyplatypus-0.2.0a1/pyplatypus/training/trainer.py +174 -0
  36. pyplatypus-0.2.0a1/pyplatypus.egg-info/PKG-INFO +117 -0
  37. pyplatypus-0.2.0a1/pyplatypus.egg-info/SOURCES.txt +65 -0
  38. pyplatypus-0.2.0a1/pyplatypus.egg-info/dependency_links.txt +1 -0
  39. pyplatypus-0.2.0a1/pyplatypus.egg-info/entry_points.txt +2 -0
  40. pyplatypus-0.2.0a1/pyplatypus.egg-info/requires.txt +16 -0
  41. pyplatypus-0.2.0a1/pyplatypus.egg-info/top_level.txt +1 -0
  42. pyplatypus-0.2.0a1/pyproject.toml +74 -0
  43. pyplatypus-0.2.0a1/setup.cfg +4 -0
  44. pyplatypus-0.2.0a1/tests/conftest.py +61 -0
  45. pyplatypus-0.2.0a1/tests/data/test_dataset.py +102 -0
  46. pyplatypus-0.2.0a1/tests/data/test_dsbowl.py +108 -0
  47. pyplatypus-0.2.0a1/tests/data/test_images.py +72 -0
  48. pyplatypus-0.2.0a1/tests/data/test_masks.py +59 -0
  49. pyplatypus-0.2.0a1/tests/data/test_paths.py +88 -0
  50. pyplatypus-0.2.0a1/tests/fixtures/experiment.yaml +51 -0
  51. pyplatypus-0.2.0a1/tests/models/test_learning.py +148 -0
  52. pyplatypus-0.2.0a1/tests/models/test_unet.py +135 -0
  53. pyplatypus-0.2.0a1/tests/objectives/test_functional.py +142 -0
  54. pyplatypus-0.2.0a1/tests/objectives/test_losses.py +120 -0
  55. pyplatypus-0.2.0a1/tests/objectives/test_metrics.py +111 -0
  56. pyplatypus-0.2.0a1/tests/spec/test_components.py +78 -0
  57. pyplatypus-0.2.0a1/tests/spec/test_errors.py +76 -0
  58. pyplatypus-0.2.0a1/tests/spec/test_schema.py +34 -0
  59. pyplatypus-0.2.0a1/tests/spec/test_shapes.py +45 -0
  60. pyplatypus-0.2.0a1/tests/spec/test_spec.py +60 -0
  61. pyplatypus-0.2.0a1/tests/spec/test_tiling.py +63 -0
  62. pyplatypus-0.2.0a1/tests/spec/test_yaml.py +64 -0
  63. pyplatypus-0.2.0a1/tests/test_engine.py +163 -0
  64. pyplatypus-0.2.0a1/tests/training/test_callbacks.py +127 -0
  65. pyplatypus-0.2.0a1/tests/training/test_no_warnings.py +31 -0
  66. pyplatypus-0.2.0a1/tests/training/test_optimizers.py +39 -0
  67. pyplatypus-0.2.0a1/tests/training/test_trainer.py +120 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Michał Maj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1,11 @@
1
+ # Without this, setuptools picks up tests/test_engine.py and nothing below it: top-level
2
+ # test files are included by default, nested ones are not. Half a suite helps nobody, and
3
+ # these tests carry a lot of the reasoning behind the design, so they all ship.
4
+ graft tests
5
+ include examples/data_science_bowl.yaml
6
+
7
+ # Datasets, trained weights and run output never belong in a distribution.
8
+ prune examples/data
9
+ prune examples/output
10
+ prune _legacy
11
+ global-exclude __pycache__ *.py[cod] *.pt *.h5 *.hdf5
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyplatypus
3
+ Version: 0.2.0a1
4
+ Summary: Computer vision for medical imaging: the engine behind the platypus R package.
5
+ Author-email: Michal Maj <michalmaj116@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/maju116/pyplatypus
8
+ Project-URL: Repository, https://github.com/maju116/pyplatypus
9
+ Project-URL: Issues, https://github.com/maju116/pyplatypus/issues
10
+ Project-URL: R package, https://github.com/maju116/platypus
11
+ Keywords: computer-vision,segmentation,medical-imaging,u-net,pytorch,deep-learning
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Intended Audience :: Healthcare Industry
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
23
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: pydantic>=2.7
28
+ Requires-Dist: pyyaml>=6.0
29
+ Requires-Dist: numpy>=1.24
30
+ Requires-Dist: pillow>=10.0
31
+ Requires-Dist: albumentations>=1.4
32
+ Requires-Dist: torch>=2.7
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest>=8; extra == "dev"
35
+ Requires-Dist: pytest-cov; extra == "dev"
36
+ Requires-Dist: ruff>=0.6; extra == "dev"
37
+ Requires-Dist: build; extra == "dev"
38
+ Requires-Dist: twine; extra == "dev"
39
+ Provides-Extra: pascal
40
+ Requires-Dist: torch<2.8,>=2.7; extra == "pascal"
41
+ Dynamic: license-file
42
+
43
+ <img src="https://raw.githubusercontent.com/maju116/platypus/master/man/figures/hexsticker_platypus.png" align="right" alt="" width="130" />
44
+
45
+ # pyplatypus
46
+
47
+ **Computer vision for medical imaging — the engine behind the `platypus` R package.**
48
+
49
+ > **0.2.0a1 — an alpha.** This replaces the 2022 TensorFlow package with a PyTorch one.
50
+ > The API will still move and the R surface does not exist yet, so pin the exact version
51
+ > if you build on it.
52
+ >
53
+ > Everything on PyPI so far is a pre-release, so `pip install pyplatypus` resolves to this
54
+ > one. `pip install pyplatypus==0.1.0rc2` gets the old TensorFlow package.
55
+
56
+ ## What works today
57
+
58
+ Semantic segmentation in 2D, end to end:
59
+
60
+ - **One spec, two ways in.** Build it from arguments or load it from YAML — both produce
61
+ the same object, so nothing downstream can tell which you used.
62
+ - **Four architectures**: U-Net, U-Net++, Res-U-Net, LinkNet, each composable with
63
+ separable convolutions, spatial dropout, learned or interpolated upsampling, deep
64
+ supervision and configurable block width.
65
+ - **Nine losses** (IoU, Dice, CCE, CCE-Dice, Focal, Tversky, Focal-Tversky, Combo,
66
+ Lovász) and three metrics, all reducing over every axis except batch and channel — so
67
+ they already work on volumes.
68
+ - **Tiling that goes both ways**: cut a large image into a grid instead of shrinking it,
69
+ and get a full-size mask back.
70
+ - **Many models from one file**, with a comparison table at the end.
71
+
72
+ 3D, object detection, ensembling and pretrained backbones are deliberately out of scope
73
+ for v0.1. The spec and the model builder already handle volumes; the data pipeline is
74
+ where 3D stops.
75
+
76
+ ## Try it
77
+
78
+ ```bash
79
+ uv venv --python 3.11 .venv
80
+ uv pip install --python .venv/bin/python -e ".[dev]"
81
+ .venv/bin/python -m pytest
82
+ ```
83
+
84
+ ```python
85
+ from pyplatypus import Engine, from_yaml
86
+
87
+ engine = Engine(from_yaml("examples/data_science_bowl.yaml"))
88
+ engine.fit(verbose=True)
89
+
90
+ for row in engine.evaluate():
91
+ print(row)
92
+
93
+ masks = engine.predict(engine.best_model("dice"), split="test")
94
+ ```
95
+
96
+ `examples/data_science_bowl.yaml` trains a U-Net and a LinkNet on the 2018 Data Science
97
+ Bowl and prints a comparison. On a GTX 1070 that is about 11 seconds per epoch at
98
+ 160×160.
99
+
100
+ ## Requirements
101
+
102
+ Python ≥ 3.10, and torch ≥ 2.7.
103
+
104
+ **If your GPU is a GTX 10-series (Pascal) or older**, install the `pascal` extra:
105
+
106
+ ```bash
107
+ pip install "pyplatypus[pascal]"
108
+ ```
109
+
110
+ torch 2.8 and later ship CUDA 13 builds, and CUDA 13 dropped the Maxwell, Pascal and
111
+ Volta generations outright - no driver update brings them back. The last torch built
112
+ against CUDA 12 is 2.7.x, which the extra pins. On anything from Turing (RTX 20-series)
113
+ onwards, ignore this.
114
+
115
+ ## Licence
116
+
117
+ MIT.
@@ -0,0 +1,75 @@
1
+ <img src="https://raw.githubusercontent.com/maju116/platypus/master/man/figures/hexsticker_platypus.png" align="right" alt="" width="130" />
2
+
3
+ # pyplatypus
4
+
5
+ **Computer vision for medical imaging — the engine behind the `platypus` R package.**
6
+
7
+ > **0.2.0a1 — an alpha.** This replaces the 2022 TensorFlow package with a PyTorch one.
8
+ > The API will still move and the R surface does not exist yet, so pin the exact version
9
+ > if you build on it.
10
+ >
11
+ > Everything on PyPI so far is a pre-release, so `pip install pyplatypus` resolves to this
12
+ > one. `pip install pyplatypus==0.1.0rc2` gets the old TensorFlow package.
13
+
14
+ ## What works today
15
+
16
+ Semantic segmentation in 2D, end to end:
17
+
18
+ - **One spec, two ways in.** Build it from arguments or load it from YAML — both produce
19
+ the same object, so nothing downstream can tell which you used.
20
+ - **Four architectures**: U-Net, U-Net++, Res-U-Net, LinkNet, each composable with
21
+ separable convolutions, spatial dropout, learned or interpolated upsampling, deep
22
+ supervision and configurable block width.
23
+ - **Nine losses** (IoU, Dice, CCE, CCE-Dice, Focal, Tversky, Focal-Tversky, Combo,
24
+ Lovász) and three metrics, all reducing over every axis except batch and channel — so
25
+ they already work on volumes.
26
+ - **Tiling that goes both ways**: cut a large image into a grid instead of shrinking it,
27
+ and get a full-size mask back.
28
+ - **Many models from one file**, with a comparison table at the end.
29
+
30
+ 3D, object detection, ensembling and pretrained backbones are deliberately out of scope
31
+ for v0.1. The spec and the model builder already handle volumes; the data pipeline is
32
+ where 3D stops.
33
+
34
+ ## Try it
35
+
36
+ ```bash
37
+ uv venv --python 3.11 .venv
38
+ uv pip install --python .venv/bin/python -e ".[dev]"
39
+ .venv/bin/python -m pytest
40
+ ```
41
+
42
+ ```python
43
+ from pyplatypus import Engine, from_yaml
44
+
45
+ engine = Engine(from_yaml("examples/data_science_bowl.yaml"))
46
+ engine.fit(verbose=True)
47
+
48
+ for row in engine.evaluate():
49
+ print(row)
50
+
51
+ masks = engine.predict(engine.best_model("dice"), split="test")
52
+ ```
53
+
54
+ `examples/data_science_bowl.yaml` trains a U-Net and a LinkNet on the 2018 Data Science
55
+ Bowl and prints a comparison. On a GTX 1070 that is about 11 seconds per epoch at
56
+ 160×160.
57
+
58
+ ## Requirements
59
+
60
+ Python ≥ 3.10, and torch ≥ 2.7.
61
+
62
+ **If your GPU is a GTX 10-series (Pascal) or older**, install the `pascal` extra:
63
+
64
+ ```bash
65
+ pip install "pyplatypus[pascal]"
66
+ ```
67
+
68
+ torch 2.8 and later ship CUDA 13 builds, and CUDA 13 dropped the Maxwell, Pascal and
69
+ Volta generations outright - no driver update brings them back. The last torch built
70
+ against CUDA 12 is 2.7.x, which the extra pins. On anything from Turing (RTX 20-series)
71
+ onwards, ignore this.
72
+
73
+ ## Licence
74
+
75
+ MIT.
@@ -0,0 +1,89 @@
1
+ # 2018 Data Science Bowl: find the nuclei.
2
+ #
3
+ # Two models, one file, one run. This is the whole point of the YAML path - and the
4
+ # identical spec can be built from arguments in R and behave the same way.
5
+
6
+ data:
7
+ train_path: examples/data/data_science_bowl/stage1_train
8
+ validation_path: examples/data/data_science_bowl/stage1_validation
9
+ test_path: examples/data/data_science_bowl/stage1_test
10
+ mode: nested_dirs
11
+ colormap:
12
+ - [0, 0, 0] # background
13
+ - [255, 255, 255] # nucleus
14
+ shuffle: true
15
+
16
+ output_dir: examples/output
17
+ seed: 0
18
+
19
+ models:
20
+ - name: unet
21
+ architecture: u_net
22
+ input_shape: [160, 160]
23
+ channels: 3
24
+ n_class: 2
25
+ blocks: 4
26
+ filters: 16
27
+ dropout: 0.1
28
+ loss:
29
+ name: cce_dice
30
+ cce_weight: 0.5
31
+ metrics:
32
+ - name: dice
33
+ include_background: false # nuclei are a small part of the picture
34
+ - name: iou
35
+ include_background: false
36
+ optimizer:
37
+ name: adam
38
+ learning_rate: 0.001
39
+ callbacks:
40
+ - name: early_stopping
41
+ monitor: val_dice
42
+ patience: 4
43
+ - name: model_checkpoint
44
+ path: examples/output/unet.pt
45
+ monitor: val_dice
46
+ - name: csv_logger
47
+ path: examples/output/unet_history.csv
48
+ - name: terminate_on_nan
49
+ augmentation:
50
+ - name: HorizontalFlip
51
+ params: {p: 0.5}
52
+ - name: VerticalFlip
53
+ params: {p: 0.5}
54
+ - name: RandomRotate90
55
+ params: {p: 0.5}
56
+ epochs: 6
57
+ batch_size: 8
58
+
59
+ - name: linknet
60
+ architecture: linknet
61
+ input_shape: [160, 160]
62
+ channels: 3
63
+ n_class: 2
64
+ blocks: 4
65
+ filters: 16
66
+ dropout: 0.1
67
+ loss:
68
+ name: focal_tversky
69
+ alpha: 0.7 # weight missed nuclei more than false alarms
70
+ gamma: 1.3
71
+ metrics:
72
+ - name: dice
73
+ include_background: false
74
+ - name: iou
75
+ include_background: false
76
+ optimizer:
77
+ name: adamw
78
+ learning_rate: 0.001
79
+ callbacks:
80
+ - name: early_stopping
81
+ monitor: val_dice
82
+ patience: 4
83
+ - name: model_checkpoint
84
+ path: examples/output/linknet.pt
85
+ monitor: val_dice
86
+ - name: csv_logger
87
+ path: examples/output/linknet_history.csv
88
+ epochs: 6
89
+ batch_size: 8
@@ -0,0 +1,23 @@
1
+ """pyplatypus - the engine behind the platypus R package.
2
+
3
+ v0.1: specification, data pipeline, U-shaped models, losses and metrics, training.
4
+ 2D segmentation. The spec and the model builder already handle volumes; the data
5
+ pipeline is where 3D stops for now.
6
+ """
7
+
8
+ from pyplatypus.engine import Engine
9
+ from pyplatypus.errors import ConfigError, PlatypusError
10
+ from pyplatypus.spec import PlatypusSpec, from_dict, from_yaml, spec_schema, write_schema
11
+
12
+ __version__ = "0.2.0a1"
13
+ __all__ = [
14
+ "ConfigError",
15
+ "Engine",
16
+ "PlatypusError",
17
+ "PlatypusSpec",
18
+ "__version__",
19
+ "from_dict",
20
+ "from_yaml",
21
+ "spec_schema",
22
+ "write_schema",
23
+ ]
@@ -0,0 +1,16 @@
1
+ from pyplatypus.data.augmentation import Augmenter, build_augmenter
2
+ from pyplatypus.data.dataset import SegmentationDataset
3
+ from pyplatypus.data.images import read_image, stitch, tile, to_float
4
+ from pyplatypus.data.masks import (
5
+ classes_to_onehot,
6
+ colours_to_classes,
7
+ onehot_to_colours,
8
+ unite_masks,
9
+ )
10
+ from pyplatypus.data.paths import Discovery, Sample, discover
11
+
12
+ __all__ = [
13
+ "Augmenter", "Discovery", "Sample", "SegmentationDataset", "build_augmenter",
14
+ "classes_to_onehot", "colours_to_classes", "discover", "onehot_to_colours",
15
+ "read_image", "stitch", "tile", "to_float", "unite_masks",
16
+ ]
@@ -0,0 +1,77 @@
1
+ """Augmentation, behind an interface.
2
+
3
+ PLAN.md rule 4: the spec names transforms, it does not import a library. albumentations
4
+ is the backend; swapping or adding one later must not touch a single spec.
5
+
6
+ Masks travel through here as class indices, never as one-hot or RGB, so a geometric
7
+ transform moves labels around instead of blending them into colours that match no class.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Protocol
13
+
14
+ import numpy as np
15
+
16
+ from pyplatypus.errors import PlatypusError
17
+ from pyplatypus.spec.components import AugmentationStep
18
+
19
+
20
+ class AugmentationError(PlatypusError):
21
+ kind = "augmentation_error"
22
+
23
+
24
+ class Augmenter(Protocol):
25
+ """Takes an image and its class-index mask, returns both transformed."""
26
+
27
+ def __call__(self, image: np.ndarray, mask: np.ndarray | None = None
28
+ ) -> tuple[np.ndarray, np.ndarray | None]: ...
29
+
30
+
31
+ class AlbumentationsAugmenter:
32
+ def __init__(self, steps: list[AugmentationStep]):
33
+ try:
34
+ import albumentations
35
+ except ImportError: # pragma: no cover
36
+ raise AugmentationError(
37
+ "augmentation was requested but albumentations is not installed"
38
+ ) from None
39
+
40
+ built = []
41
+ for step in steps:
42
+ factory = getattr(albumentations, step.name, None)
43
+ if factory is None:
44
+ raise AugmentationError(
45
+ f"albumentations {albumentations.__version__} has no transform "
46
+ f"'{step.name}'"
47
+ )
48
+ try:
49
+ built.append(factory(**step.params))
50
+ except TypeError as error:
51
+ raise AugmentationError(
52
+ f"'{step.name}' rejected its parameters {step.params}: {error}"
53
+ ) from None
54
+ self._pipeline = albumentations.Compose(built)
55
+ self.steps = tuple(step.name for step in steps)
56
+
57
+ def __call__(self, image: np.ndarray, mask: np.ndarray | None = None
58
+ ) -> tuple[np.ndarray, np.ndarray | None]:
59
+ if mask is None:
60
+ return self._pipeline(image=image)["image"], None
61
+ out = self._pipeline(image=image, mask=mask)
62
+ return out["image"], out["mask"]
63
+
64
+
65
+ def build_augmenter(steps: list[AugmentationStep] | None, rank: int = 2
66
+ ) -> Augmenter | None:
67
+ """None when there is nothing to do, which keeps the caller free of special cases."""
68
+ if not steps:
69
+ return None
70
+ if rank != 2:
71
+ # albumentations 2.x does ship 3D transforms (CenterCrop3D, CubicSymmetry and
72
+ # friends) through a different call signature, so this is a v0.1 scope line
73
+ # rather than a missing capability.
74
+ raise AugmentationError(
75
+ f"augmentation is implemented for 2D only; this spec is {rank}D"
76
+ )
77
+ return AlbumentationsAugmenter(steps)
@@ -0,0 +1,116 @@
1
+ """One sample in, one training example out.
2
+
3
+ Numpy only. The torch `Dataset` wrapper is a dozen lines and arrives in step 3; keeping
4
+ it out of here means this layer stays fast to test and usable on its own.
5
+
6
+ Arrays are channels-last throughout, because that is what albumentations and PIL speak.
7
+ The torch adapter transposes once, at the boundary.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import OrderedDict
13
+
14
+ import numpy as np
15
+
16
+ from pyplatypus.data.augmentation import Augmenter
17
+ from pyplatypus.data.images import read_image, tile, to_float
18
+ from pyplatypus.data.masks import classes_to_onehot, colours_to_classes, unite_masks
19
+ from pyplatypus.data.paths import Sample
20
+ from pyplatypus.errors import PlatypusError
21
+ from pyplatypus.spec.data import SegmentationData
22
+ from pyplatypus.spec.models import SegmentationModel
23
+
24
+
25
+ class DataError(PlatypusError):
26
+ kind = "data_error"
27
+
28
+
29
+ class SegmentationDataset:
30
+ """Samples on disk, presented as (image, one-hot mask) pairs.
31
+
32
+ When the model tiles, one source image becomes several examples, so `len()` is
33
+ samples x tiles and indexing walks the tiles of a sample before moving on.
34
+ """
35
+
36
+ def __init__(self, samples: tuple[Sample, ...], model: SegmentationModel,
37
+ data: SegmentationData, *, augmenter: Augmenter | None = None,
38
+ only_images: bool = False, cache_size: int = 8):
39
+ if model.rank != 2:
40
+ raise DataError(f"the 2D pipeline cannot serve a {model.rank}D model")
41
+ self.samples = samples
42
+ self.model = model
43
+ self.data = data
44
+ self.augmenter = augmenter
45
+ self.only_images = only_images
46
+ self._cache: OrderedDict[int, tuple[np.ndarray, np.ndarray | None]] = OrderedDict()
47
+ self._cache_size = max(1, cache_size)
48
+
49
+ @property
50
+ def tiles_per_sample(self) -> int:
51
+ return self.model.tiles_per_image
52
+
53
+ def __len__(self) -> int:
54
+ return len(self.samples) * self.tiles_per_sample
55
+
56
+ def _load(self, index: int) -> tuple[np.ndarray, np.ndarray | None]:
57
+ """Read one source sample at `load_shape`, cached because every tile asks again."""
58
+ if index in self._cache:
59
+ self._cache.move_to_end(index)
60
+ return self._cache[index]
61
+
62
+ sample = self.samples[index]
63
+ size = self.model.load_shape
64
+ image = read_image(sample.image, channels=self.model.channels, size=size)
65
+
66
+ classes: np.ndarray | None = None
67
+ if not self.only_images:
68
+ # Nearest, always: interpolating a mask invents colours that belong to no
69
+ # class and would quietly become background.
70
+ masks = [read_image(p, channels=3, size=size, nearest=True) for p in sample.masks]
71
+ united = unite_masks(masks)
72
+ classes, _ = colours_to_classes(united, self.data.colormap)
73
+
74
+ self._cache[index] = (image, classes)
75
+ if len(self._cache) > self._cache_size:
76
+ self._cache.popitem(last=False)
77
+ return image, classes
78
+
79
+ def __getitem__(self, index: int) -> tuple[np.ndarray, np.ndarray | None]:
80
+ if index < 0:
81
+ index += len(self)
82
+ if not 0 <= index < len(self):
83
+ raise IndexError(f"index {index} is outside 0..{len(self) - 1}")
84
+
85
+ sample_index, tile_index = divmod(index, self.tiles_per_sample)
86
+ image, classes = self._load(sample_index)
87
+
88
+ if self.model.splits is not None:
89
+ image = tile(image, self.model.splits)[tile_index]
90
+ if classes is not None:
91
+ classes = tile(classes[..., None], self.model.splits)[tile_index][..., 0]
92
+
93
+ if self.augmenter is not None:
94
+ image, classes = self.augmenter(image, classes)
95
+
96
+ image = to_float(image)
97
+ if classes is None:
98
+ return image, None
99
+ return image, classes_to_onehot(classes, self.model.n_class)
100
+
101
+ def colormap_coverage(self, limit: int = 20) -> float:
102
+ """Fraction of mask pixels matching no colour in the colormap.
103
+
104
+ A number near 1 means the colormap does not describe this dataset - the single
105
+ most common way a segmentation run silently trains on nothing.
106
+ """
107
+ if self.only_images:
108
+ raise DataError("there are no masks to check")
109
+ unmatched = []
110
+ for index in range(min(limit, len(self.samples))):
111
+ sample = self.samples[index]
112
+ masks = [read_image(p, channels=3, size=self.model.load_shape, nearest=True)
113
+ for p in sample.masks]
114
+ _, fraction = colours_to_classes(unite_masks(masks), self.data.colormap)
115
+ unmatched.append(fraction)
116
+ return float(np.mean(unmatched))
@@ -0,0 +1,121 @@
1
+ """Reading pixels, and cutting them up.
2
+
3
+ `tile` and `stitch` are inverses, and there is a round-trip test that says so. The old
4
+ package could only tile: an HD image went in as a grid of pieces and the predicted pieces
5
+ were never put back together, which defeated the purpose of tiling it.
6
+
7
+ Everything is rank-generic. A 2D grid and a 3D patch grid are the same operation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ from PIL import Image
17
+
18
+ from pyplatypus.errors import PlatypusError
19
+
20
+ # PIL talks in (width, height); every shape in this package is (height, width). Mixing
21
+ # them is the classic silent bug, so the conversion happens in exactly one place.
22
+ _PIL_MODE = {1: "L", 3: "RGB", 4: "RGBA"}
23
+
24
+
25
+ class ImageError(PlatypusError):
26
+ kind = "image_error"
27
+
28
+
29
+ def read_image(path: str | Path, *, channels: int = 3,
30
+ size: tuple[int, ...] | None = None,
31
+ nearest: bool = False) -> np.ndarray:
32
+ """Read one image as a channels-last float32 array scaled to 0-1.
33
+
34
+ `nearest` must be used for masks: interpolating a mask invents colours that match no
35
+ class, which then silently become background.
36
+ """
37
+ mode = _PIL_MODE.get(channels)
38
+ if mode is None:
39
+ raise ImageError(f"channels must be 1, 3 or 4 for ordinary images, got {channels}")
40
+
41
+ try:
42
+ with Image.open(path) as handle:
43
+ picture = handle.convert(mode)
44
+ if size is not None:
45
+ if len(size) != 2:
46
+ raise ImageError(
47
+ f"2D readers take a (height, width), got {tuple(size)}"
48
+ )
49
+ resample = Image.Resampling.NEAREST if nearest else Image.Resampling.BILINEAR
50
+ picture = picture.resize((size[1], size[0]), resample=resample)
51
+ array = np.asarray(picture, dtype=np.uint8)
52
+ except OSError as error:
53
+ raise ImageError(f"could not read '{path}': {error}") from None
54
+
55
+ if array.ndim == 2:
56
+ array = array[..., None]
57
+ return array
58
+
59
+
60
+ def to_float(array: np.ndarray) -> np.ndarray:
61
+ """0-255 integers to 0-1 floats, leaving anything already floating alone."""
62
+ if np.issubdtype(array.dtype, np.floating):
63
+ return array.astype(np.float32, copy=False)
64
+ return array.astype(np.float32) / 255.0
65
+
66
+
67
+ def tile(array: np.ndarray, splits: tuple[int, ...]) -> np.ndarray:
68
+ """Cut a channels-last array into a grid of tiles.
69
+
70
+ Returns shape (n_tiles, *tile_shape, channels), in row-major order: for splits (2, 3)
71
+ the tiles come out as (0,0), (0,1), (0,2), (1,0), (1,1), (1,2). `stitch` relies on
72
+ that order.
73
+ """
74
+ rank = len(splits)
75
+ spatial = array.shape[:rank]
76
+ if array.ndim != rank + 1:
77
+ raise ImageError(
78
+ f"expected {rank} spatial dimensions plus channels, got shape {array.shape}"
79
+ )
80
+ bad = [(size, n) for size, n in zip(spatial, splits, strict=True) if size % n]
81
+ if bad:
82
+ raise ImageError(
83
+ f"cannot cut {spatial} into {splits}: every dimension must divide exactly"
84
+ )
85
+
86
+ tile_shape = tuple(size // n for size, n in zip(spatial, splits, strict=True))
87
+ channels = array.shape[-1]
88
+
89
+ # (s0, t0, s1, t1, ..., C) -> (s0, s1, ..., t0, t1, ..., C)
90
+ interleaved = []
91
+ for n, t in zip(splits, tile_shape, strict=True):
92
+ interleaved.extend((n, t))
93
+ reshaped = array.reshape(*interleaved, channels)
94
+ order = [2 * i for i in range(rank)] + [2 * i + 1 for i in range(rank)] + [2 * rank]
95
+ return reshaped.transpose(order).reshape(math.prod(splits), *tile_shape, channels)
96
+
97
+
98
+ def stitch(tiles: np.ndarray, splits: tuple[int, ...]) -> np.ndarray:
99
+ """Put tiles produced by `tile` back into one array. The inverse of `tile`."""
100
+ rank = len(splits)
101
+ expected = math.prod(splits)
102
+ if tiles.shape[0] != expected:
103
+ raise ImageError(
104
+ f"splits {splits} needs {expected} tiles, got {tiles.shape[0]}"
105
+ )
106
+ if tiles.ndim != rank + 2:
107
+ raise ImageError(
108
+ f"expected tiles shaped (n, {'x'.join('t' * rank)}, channels), "
109
+ f"got {tiles.shape}"
110
+ )
111
+
112
+ tile_shape = tiles.shape[1:-1]
113
+ channels = tiles.shape[-1]
114
+ grouped = tiles.reshape(*splits, *tile_shape, channels)
115
+ # (s0, s1, ..., t0, t1, ..., C) -> (s0, t0, s1, t1, ..., C)
116
+ order: list[int] = []
117
+ for i in range(rank):
118
+ order.extend((i, rank + i))
119
+ order.append(2 * rank)
120
+ full = tuple(n * t for n, t in zip(splits, tile_shape, strict=True))
121
+ return grouped.transpose(order).reshape(*full, channels)