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.
- pyplatypus-0.2.0a1/LICENSE +22 -0
- pyplatypus-0.2.0a1/MANIFEST.in +11 -0
- pyplatypus-0.2.0a1/PKG-INFO +117 -0
- pyplatypus-0.2.0a1/README.md +75 -0
- pyplatypus-0.2.0a1/examples/data_science_bowl.yaml +89 -0
- pyplatypus-0.2.0a1/pyplatypus/__init__.py +23 -0
- pyplatypus-0.2.0a1/pyplatypus/data/__init__.py +16 -0
- pyplatypus-0.2.0a1/pyplatypus/data/augmentation.py +77 -0
- pyplatypus-0.2.0a1/pyplatypus/data/dataset.py +116 -0
- pyplatypus-0.2.0a1/pyplatypus/data/images.py +121 -0
- pyplatypus-0.2.0a1/pyplatypus/data/masks.py +85 -0
- pyplatypus-0.2.0a1/pyplatypus/data/paths.py +152 -0
- pyplatypus-0.2.0a1/pyplatypus/engine.py +194 -0
- pyplatypus-0.2.0a1/pyplatypus/errors.py +94 -0
- pyplatypus-0.2.0a1/pyplatypus/models/__init__.py +8 -0
- pyplatypus-0.2.0a1/pyplatypus/models/encoders.py +68 -0
- pyplatypus-0.2.0a1/pyplatypus/models/layers.py +149 -0
- pyplatypus-0.2.0a1/pyplatypus/models/unet.py +148 -0
- pyplatypus-0.2.0a1/pyplatypus/objectives/__init__.py +6 -0
- pyplatypus-0.2.0a1/pyplatypus/objectives/functional.py +157 -0
- pyplatypus-0.2.0a1/pyplatypus/objectives/losses.py +151 -0
- pyplatypus-0.2.0a1/pyplatypus/objectives/metrics.py +85 -0
- pyplatypus-0.2.0a1/pyplatypus/spec/__init__.py +32 -0
- pyplatypus-0.2.0a1/pyplatypus/spec/common.py +57 -0
- pyplatypus-0.2.0a1/pyplatypus/spec/components.py +311 -0
- pyplatypus-0.2.0a1/pyplatypus/spec/data.py +69 -0
- pyplatypus-0.2.0a1/pyplatypus/spec/loader.py +54 -0
- pyplatypus-0.2.0a1/pyplatypus/spec/models.py +177 -0
- pyplatypus-0.2.0a1/pyplatypus/spec/schema.py +35 -0
- pyplatypus-0.2.0a1/pyplatypus/spec/spec.py +71 -0
- pyplatypus-0.2.0a1/pyplatypus/training/__init__.py +14 -0
- pyplatypus-0.2.0a1/pyplatypus/training/callbacks.py +209 -0
- pyplatypus-0.2.0a1/pyplatypus/training/optimizers.py +54 -0
- pyplatypus-0.2.0a1/pyplatypus/training/torch_data.py +48 -0
- pyplatypus-0.2.0a1/pyplatypus/training/trainer.py +174 -0
- pyplatypus-0.2.0a1/pyplatypus.egg-info/PKG-INFO +117 -0
- pyplatypus-0.2.0a1/pyplatypus.egg-info/SOURCES.txt +65 -0
- pyplatypus-0.2.0a1/pyplatypus.egg-info/dependency_links.txt +1 -0
- pyplatypus-0.2.0a1/pyplatypus.egg-info/entry_points.txt +2 -0
- pyplatypus-0.2.0a1/pyplatypus.egg-info/requires.txt +16 -0
- pyplatypus-0.2.0a1/pyplatypus.egg-info/top_level.txt +1 -0
- pyplatypus-0.2.0a1/pyproject.toml +74 -0
- pyplatypus-0.2.0a1/setup.cfg +4 -0
- pyplatypus-0.2.0a1/tests/conftest.py +61 -0
- pyplatypus-0.2.0a1/tests/data/test_dataset.py +102 -0
- pyplatypus-0.2.0a1/tests/data/test_dsbowl.py +108 -0
- pyplatypus-0.2.0a1/tests/data/test_images.py +72 -0
- pyplatypus-0.2.0a1/tests/data/test_masks.py +59 -0
- pyplatypus-0.2.0a1/tests/data/test_paths.py +88 -0
- pyplatypus-0.2.0a1/tests/fixtures/experiment.yaml +51 -0
- pyplatypus-0.2.0a1/tests/models/test_learning.py +148 -0
- pyplatypus-0.2.0a1/tests/models/test_unet.py +135 -0
- pyplatypus-0.2.0a1/tests/objectives/test_functional.py +142 -0
- pyplatypus-0.2.0a1/tests/objectives/test_losses.py +120 -0
- pyplatypus-0.2.0a1/tests/objectives/test_metrics.py +111 -0
- pyplatypus-0.2.0a1/tests/spec/test_components.py +78 -0
- pyplatypus-0.2.0a1/tests/spec/test_errors.py +76 -0
- pyplatypus-0.2.0a1/tests/spec/test_schema.py +34 -0
- pyplatypus-0.2.0a1/tests/spec/test_shapes.py +45 -0
- pyplatypus-0.2.0a1/tests/spec/test_spec.py +60 -0
- pyplatypus-0.2.0a1/tests/spec/test_tiling.py +63 -0
- pyplatypus-0.2.0a1/tests/spec/test_yaml.py +64 -0
- pyplatypus-0.2.0a1/tests/test_engine.py +163 -0
- pyplatypus-0.2.0a1/tests/training/test_callbacks.py +127 -0
- pyplatypus-0.2.0a1/tests/training/test_no_warnings.py +31 -0
- pyplatypus-0.2.0a1/tests/training/test_optimizers.py +39 -0
- 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)
|