deepcontrail-segmentation 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.
- deepcontrail/segmentation/__init__.py +51 -0
- deepcontrail/segmentation/config.py +94 -0
- deepcontrail/segmentation/dataset.py +275 -0
- deepcontrail/segmentation/losses/__init__.py +21 -0
- deepcontrail/segmentation/losses/config.py +87 -0
- deepcontrail/segmentation/losses/losses.py +247 -0
- deepcontrail/segmentation/models/__init__.py +9 -0
- deepcontrail/segmentation/models/config.py +71 -0
- deepcontrail/segmentation/models/factory.py +92 -0
- deepcontrail/segmentation/models/stem.py +50 -0
- deepcontrail/segmentation/pretrained.py +272 -0
- deepcontrail/segmentation/py.typed +0 -0
- deepcontrail/segmentation/tensors.py +104 -0
- deepcontrail/segmentation/training/__init__.py +43 -0
- deepcontrail/segmentation/training/config.py +224 -0
- deepcontrail/segmentation/training/evaluation.py +234 -0
- deepcontrail/segmentation/training/module.py +190 -0
- deepcontrail/segmentation/training/run.py +379 -0
- deepcontrail/segmentation/training/tta.py +95 -0
- deepcontrail/segmentation/transforms.py +118 -0
- deepcontrail_segmentation-0.1.0.dist-info/METADATA +139 -0
- deepcontrail_segmentation-0.1.0.dist-info/RECORD +25 -0
- deepcontrail_segmentation-0.1.0.dist-info/WHEEL +4 -0
- deepcontrail_segmentation-0.1.0.dist-info/entry_points.txt +3 -0
- deepcontrail_segmentation-0.1.0.dist-info/licenses/LICENSE +211 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Semantic segmentation of contrails in satellite imagery.
|
|
2
|
+
|
|
3
|
+
The data pipeline imported here is torch-free; ``models``, ``losses`` and
|
|
4
|
+
``training`` are subpackages that need the training stack. ``train`` (from
|
|
5
|
+
``training``), ``load_pretrained`` and ``predict`` (from ``pretrained``) are loaded
|
|
6
|
+
on first access, so this import stays light.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from deepcontrail.segmentation.config import (
|
|
13
|
+
AugmentationConfig,
|
|
14
|
+
NormalizeConfig,
|
|
15
|
+
SegmentationDataConfig,
|
|
16
|
+
)
|
|
17
|
+
from deepcontrail.segmentation.dataset import SegmentationDataset
|
|
18
|
+
|
|
19
|
+
__version__ = version("deepcontrail-segmentation")
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from deepcontrail.segmentation.pretrained import load_pretrained, predict
|
|
23
|
+
from deepcontrail.segmentation.training.run import train
|
|
24
|
+
|
|
25
|
+
_LAZY = {
|
|
26
|
+
"train": "deepcontrail.segmentation.training.run",
|
|
27
|
+
"load_pretrained": "deepcontrail.segmentation.pretrained",
|
|
28
|
+
"predict": "deepcontrail.segmentation.pretrained",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def __getattr__(name: str) -> Any: # noqa: ANN401
|
|
33
|
+
"""Load ``train``, ``load_pretrained`` and ``predict`` lazily: they import torch."""
|
|
34
|
+
if name in _LAZY:
|
|
35
|
+
import importlib
|
|
36
|
+
|
|
37
|
+
return getattr(importlib.import_module(_LAZY[name]), name)
|
|
38
|
+
msg = f"module {__name__!r} has no attribute {name!r}"
|
|
39
|
+
raise AttributeError(msg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"AugmentationConfig",
|
|
44
|
+
"NormalizeConfig",
|
|
45
|
+
"SegmentationDataConfig",
|
|
46
|
+
"SegmentationDataset",
|
|
47
|
+
"__version__",
|
|
48
|
+
"load_pretrained",
|
|
49
|
+
"predict",
|
|
50
|
+
"train",
|
|
51
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Pydantic configuration of the segmentation data pipeline.
|
|
2
|
+
|
|
3
|
+
Uses only pydantic so that ``import deepcontrail.segmentation`` stays torch-free.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated, Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from deepcontrail.core.config import DatasetConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NormalizeConfig(BaseModel):
|
|
16
|
+
"""Channel-wise normalization statistics.
|
|
17
|
+
|
|
18
|
+
Defaults to ImageNet mean/std, the standard choice when fine-tuning
|
|
19
|
+
pretrained encoders (ResNet, EfficientNet, etc.).
|
|
20
|
+
|
|
21
|
+
For model-specific normalization, training pipelines should query
|
|
22
|
+
``smp.encoders.get_preprocessing_params(encoder_name)`` and pass the
|
|
23
|
+
resulting ``mean``/``std`` here to match the encoder's expectations.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
27
|
+
|
|
28
|
+
mean: tuple[float, float, float] = (0.485, 0.456, 0.406)
|
|
29
|
+
std: tuple[float, float, float] = (0.229, 0.224, 0.225)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AugmentationConfig(BaseModel):
|
|
33
|
+
"""Spatial and photometric augmentation probabilities.
|
|
34
|
+
|
|
35
|
+
Each probability defaults to ``0.0`` (disabled) except the three spatial
|
|
36
|
+
flips/rotates, so existing YAML configs without the new fields keep their
|
|
37
|
+
previous behaviour. Photometric transforms (``gaussian_blur``,
|
|
38
|
+
``brightness_contrast``) apply to the image only — albumentations
|
|
39
|
+
co-transforms the mask for the spatial ones.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
43
|
+
|
|
44
|
+
# Spatial — applied to both image and mask.
|
|
45
|
+
horizontal_flip: Annotated[float, Field(ge=0.0, le=1.0)] = 0.5
|
|
46
|
+
vertical_flip: Annotated[float, Field(ge=0.0, le=1.0)] = 0.5
|
|
47
|
+
random_rotate_90: Annotated[float, Field(ge=0.0, le=1.0)] = 0.5
|
|
48
|
+
shift_scale_rotate: Annotated[float, Field(ge=0.0, le=1.0)] = 0.0
|
|
49
|
+
shift_limit: float = 0.0625
|
|
50
|
+
scale_limit: float = 0.1
|
|
51
|
+
rotate_limit: int = 45
|
|
52
|
+
# Photometric / pixel-level — image only.
|
|
53
|
+
gaussian_blur: Annotated[float, Field(ge=0.0, le=1.0)] = 0.0
|
|
54
|
+
blur_limit: tuple[int, int] = (3, 7)
|
|
55
|
+
brightness_contrast: Annotated[float, Field(ge=0.0, le=1.0)] = 0.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SegmentationDataConfig(DatasetConfig):
|
|
59
|
+
"""Complete segmentation data pipeline configuration.
|
|
60
|
+
|
|
61
|
+
Extends ``DatasetConfig`` (root, split, image construction, pixel shift and
|
|
62
|
+
train/val partition) with the label, resize, augmentation, normalization
|
|
63
|
+
and DataLoader parameters (batch_size, num_workers), so one flat ``data:``
|
|
64
|
+
YAML section reproduces a dataset setup.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
mask_type
|
|
69
|
+
Which label artifact to load. ``"binary"`` reads ``semantic_masks/``
|
|
70
|
+
PNGs (strict consensus ground truth). ``"probability"`` reads
|
|
71
|
+
``probability_masks/`` and returns float32 in ``[0, 1]`` for
|
|
72
|
+
soft-label training.
|
|
73
|
+
image_size
|
|
74
|
+
Expected spatial size of images (height and width).
|
|
75
|
+
batch_size
|
|
76
|
+
Batch size for the DataLoader (not used by the Dataset itself).
|
|
77
|
+
num_workers
|
|
78
|
+
Number of DataLoader workers (not used by the Dataset itself).
|
|
79
|
+
augmentation
|
|
80
|
+
Augmentation config for training, or ``None`` for no augmentation.
|
|
81
|
+
normalize
|
|
82
|
+
Channel-wise normalization statistics.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# Unlike the shared core DatasetConfig, the segmentation data section is complete here, so a
|
|
86
|
+
# misspelt key is an error instead of a silently ignored setting.
|
|
87
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
88
|
+
|
|
89
|
+
mask_type: Literal["binary", "probability"] = "binary"
|
|
90
|
+
image_size: Annotated[int, Field(gt=0)] = 256
|
|
91
|
+
batch_size: Annotated[int, Field(gt=0)] = 16
|
|
92
|
+
num_workers: Annotated[int, Field(ge=0)] = 4
|
|
93
|
+
augmentation: AugmentationConfig | None = None
|
|
94
|
+
normalize: NormalizeConfig = NormalizeConfig()
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Semantic segmentation dataset over a DeepContrail-format dataset root.
|
|
2
|
+
|
|
3
|
+
Builds each image from ``spectral_bands/`` with the configured false-colour recipe
|
|
4
|
+
(band keys from the root's ``bands.json``) and pairs it with its mask.
|
|
5
|
+
``import deepcontrail.segmentation`` requires only numpy + Pillow — torch is
|
|
6
|
+
imported lazily inside ``__getitem__`` and is optional.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import warnings
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from deepcontrail.core.contract import read_band_manifest, read_dataset_info
|
|
18
|
+
from deepcontrail.core.false_color import check_false_color, load_spectral_false_color
|
|
19
|
+
from deepcontrail.core.readers import (
|
|
20
|
+
binarize_mask,
|
|
21
|
+
load_mask,
|
|
22
|
+
shift_image,
|
|
23
|
+
to_numpy,
|
|
24
|
+
validate_binary_mask,
|
|
25
|
+
)
|
|
26
|
+
from deepcontrail.core.splits import context_frames, partition_stems
|
|
27
|
+
from deepcontrail.segmentation.tensors import to_tensors, to_tensors_soft
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
import albumentations as A
|
|
31
|
+
|
|
32
|
+
from deepcontrail.segmentation.config import SegmentationDataConfig
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SegmentationDataset:
|
|
36
|
+
"""Per-sample semantic segmentation dataset over a DeepContrail-format root.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
config
|
|
41
|
+
A ``SegmentationDataConfig`` specifying root path, split, augmentation, and
|
|
42
|
+
normalization parameters.
|
|
43
|
+
|
|
44
|
+
Raises
|
|
45
|
+
------
|
|
46
|
+
DatasetContractError
|
|
47
|
+
If the root's ``bands.json`` or ``dataset_info.json`` is missing or invalid,
|
|
48
|
+
or if ``false_color`` uses a band key the manifest does not declare.
|
|
49
|
+
ValueError
|
|
50
|
+
If any labelled image in the split directory has no matching mask.
|
|
51
|
+
ImportError
|
|
52
|
+
If augmentation or resize is requested but albumentations is not
|
|
53
|
+
installed.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, config: SegmentationDataConfig) -> None:
|
|
57
|
+
self.config = config
|
|
58
|
+
self._root = Path(config.root)
|
|
59
|
+
self.manifest = read_band_manifest(self._root)
|
|
60
|
+
check_false_color(config.false_color, self.manifest)
|
|
61
|
+
self._warn_on_pixel_shift_mismatch()
|
|
62
|
+
|
|
63
|
+
img_subdir = "spectral_bands"
|
|
64
|
+
mask_subdir = "probability_masks" if config.mask_type == "probability" else "semantic_masks"
|
|
65
|
+
ds = "test" if config.split == "test" else "train"
|
|
66
|
+
self._dir_pairs = [
|
|
67
|
+
(self._root / img_subdir / ds, self._root / mask_subdir / ds),
|
|
68
|
+
]
|
|
69
|
+
self._image_dir, self._mask_dir = self._dir_pairs[0]
|
|
70
|
+
|
|
71
|
+
self._stem_dirs: dict[str, tuple[Path, Path]] = {}
|
|
72
|
+
self.stems = self._discover_stems()
|
|
73
|
+
self.native_size = self._native_size()
|
|
74
|
+
self._aug_transform, self._resize_transform = self._build_transform()
|
|
75
|
+
# DataLoader worker the augmentation was last reseeded for (None: main process).
|
|
76
|
+
self._augmentation_worker: int | None = None
|
|
77
|
+
|
|
78
|
+
def _reseed_augmentation_in_worker(self) -> None:
|
|
79
|
+
"""Give each DataLoader worker its own augmentation stream.
|
|
80
|
+
|
|
81
|
+
Every worker starts with a copy of the dataset, generators included, so without this
|
|
82
|
+
all workers would draw the same augmentations. The first item a worker loads reseeds the
|
|
83
|
+
copy from PyTorch's per-worker seed, which is reproducible under a fixed global seed and
|
|
84
|
+
changes each epoch unless the workers persist. The main process keeps the
|
|
85
|
+
configuration's seed.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
from torch.utils.data import get_worker_info
|
|
89
|
+
except ImportError: # without torch there are no DataLoader workers
|
|
90
|
+
return
|
|
91
|
+
info = get_worker_info()
|
|
92
|
+
if info is None or info.id == self._augmentation_worker:
|
|
93
|
+
return
|
|
94
|
+
from deepcontrail.segmentation.transforms import seed_augmentation
|
|
95
|
+
|
|
96
|
+
seed_augmentation(self._aug_transform, info.seed) # type: ignore[arg-type]
|
|
97
|
+
self._augmentation_worker = info.id
|
|
98
|
+
|
|
99
|
+
def _warn_on_pixel_shift_mismatch(self) -> None:
|
|
100
|
+
"""Warn when ``pixel_shift`` differs from the dataset's recommendation."""
|
|
101
|
+
recommended = read_dataset_info(self._root).recommended_pixel_shift
|
|
102
|
+
if recommended is not None and recommended != self.config.pixel_shift:
|
|
103
|
+
msg = (
|
|
104
|
+
f"pixel_shift={self.config.pixel_shift} but {self._root} recommends "
|
|
105
|
+
f"{recommended} (metadata/dataset_info.json): images and masks may be misaligned"
|
|
106
|
+
)
|
|
107
|
+
warnings.warn(msg, UserWarning, stacklevel=3)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def native_resolution(self) -> int:
|
|
111
|
+
"""Native square resolution of the images (256 for GOES-16)."""
|
|
112
|
+
return self.native_size
|
|
113
|
+
|
|
114
|
+
def _discover_stems(self) -> list[str]:
|
|
115
|
+
"""Scan the image directory and partition stems for the requested split.
|
|
116
|
+
|
|
117
|
+
``"test"`` returns every stem in ``test/``. ``"train"`` and ``"val"``
|
|
118
|
+
both read from ``train/`` and split deterministically:
|
|
119
|
+
|
|
120
|
+
* When ``fold`` is set, k-fold CV partitioning (modular arithmetic)
|
|
121
|
+
takes precedence.
|
|
122
|
+
* Otherwise ``val_fraction`` determines the split — stems are
|
|
123
|
+
shuffled with ``seed`` and the first ``val_fraction * N`` go to
|
|
124
|
+
validation, the rest to training.
|
|
125
|
+
"""
|
|
126
|
+
all_stems: list[str] = []
|
|
127
|
+
for img_dir, mask_dir in self._dir_pairs:
|
|
128
|
+
scanned = self._scan_stems(img_dir, mask_dir)
|
|
129
|
+
for stem in scanned:
|
|
130
|
+
self._stem_dirs[stem] = (img_dir, mask_dir)
|
|
131
|
+
all_stems.extend(scanned)
|
|
132
|
+
return partition_stems(
|
|
133
|
+
all_stems,
|
|
134
|
+
self.config.split,
|
|
135
|
+
seed=self.config.seed,
|
|
136
|
+
val_fraction=self.config.val_fraction,
|
|
137
|
+
fold=self.config.fold,
|
|
138
|
+
num_folds=self.config.num_folds,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _scan_stems(self, image_dir: Path, mask_dir: Path) -> list[str]:
|
|
142
|
+
"""Scan ``image_dir`` for paired images/masks, return sorted stems.
|
|
143
|
+
|
|
144
|
+
Checks that each image has a matching mask file; only the context frames of a dataset
|
|
145
|
+
that declares them (``temporal: true``) may have none. For deeper validation
|
|
146
|
+
(corruption, sizes, mask values), run ``deepcontrail-validate <root> --deep`` on the
|
|
147
|
+
dataset root.
|
|
148
|
+
"""
|
|
149
|
+
mask_ext = ".npy" if self.config.mask_type == "probability" else ".png"
|
|
150
|
+
image_stems = [p.stem for p in sorted(image_dir.glob("*.npy"))]
|
|
151
|
+
context = context_frames(image_stems, temporal=read_dataset_info(self._root).temporal)
|
|
152
|
+
stems: list[str] = []
|
|
153
|
+
missing: list[str] = []
|
|
154
|
+
|
|
155
|
+
for stem in image_stems:
|
|
156
|
+
mask_path = mask_dir / f"{stem}{mask_ext}"
|
|
157
|
+
if mask_path.exists():
|
|
158
|
+
stems.append(stem)
|
|
159
|
+
elif stem not in context:
|
|
160
|
+
missing.append(f"{stem}: no mask (expected {mask_path})")
|
|
161
|
+
|
|
162
|
+
if missing:
|
|
163
|
+
msg = f"Dataset validation found {len(missing)} missing mask(s):\n"
|
|
164
|
+
msg += "\n".join(f" - {e}" for e in missing)
|
|
165
|
+
raise ValueError(msg)
|
|
166
|
+
|
|
167
|
+
return stems
|
|
168
|
+
|
|
169
|
+
def _native_size(self) -> int:
|
|
170
|
+
"""Native square resolution of the images (256 for GOES-16)."""
|
|
171
|
+
if not self.stems:
|
|
172
|
+
return self.config.image_size
|
|
173
|
+
arr = np.load(self._image_dir / f"{self.stems[0]}.npy", mmap_mode="r")
|
|
174
|
+
return arr.shape[0]
|
|
175
|
+
|
|
176
|
+
def _needs_resize(self) -> bool:
|
|
177
|
+
"""Whether images must be resized to match ``image_size``.
|
|
178
|
+
|
|
179
|
+
Compares the native resolution against ``image_size``; the mask is
|
|
180
|
+
always returned at native resolution regardless.
|
|
181
|
+
"""
|
|
182
|
+
if not self.stems:
|
|
183
|
+
return False
|
|
184
|
+
return self.native_size != self.config.image_size
|
|
185
|
+
|
|
186
|
+
def _build_transform(
|
|
187
|
+
self,
|
|
188
|
+
) -> tuple[A.Compose | None, A.Resize | None]:
|
|
189
|
+
"""Build the augmentation and (optional) image-only resize transforms.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
tuple[A.Compose | None, A.Resize | None]
|
|
194
|
+
``(augmentation, image_resize)``. ``augmentation`` co-transforms
|
|
195
|
+
the image and mask at native resolution; ``image_resize`` resizes
|
|
196
|
+
only the image, when ``image_size`` differs from native.
|
|
197
|
+
"""
|
|
198
|
+
has_augmentation = self.config.augmentation is not None
|
|
199
|
+
needs_resize = self._needs_resize()
|
|
200
|
+
|
|
201
|
+
if not has_augmentation and not needs_resize:
|
|
202
|
+
return None, None
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
from deepcontrail.segmentation.transforms import build_augmentation, build_image_resize
|
|
206
|
+
except ImportError:
|
|
207
|
+
msg = (
|
|
208
|
+
"albumentations is required for augmentation and resizing. "
|
|
209
|
+
"Install it with: pip install deepcontrail[train]"
|
|
210
|
+
)
|
|
211
|
+
raise ImportError(msg) from None
|
|
212
|
+
|
|
213
|
+
augmentation = (
|
|
214
|
+
build_augmentation(self.config.augmentation, seed=self.config.seed) # type: ignore[arg-type]
|
|
215
|
+
if has_augmentation
|
|
216
|
+
else None
|
|
217
|
+
)
|
|
218
|
+
image_resize = build_image_resize(self.config.image_size) if needs_resize else None
|
|
219
|
+
return augmentation, image_resize
|
|
220
|
+
|
|
221
|
+
def __len__(self) -> int:
|
|
222
|
+
"""Return the number of image/mask pairs in this split."""
|
|
223
|
+
return len(self.stems)
|
|
224
|
+
|
|
225
|
+
def __getitem__(self, index: int) -> tuple[Any, Any]:
|
|
226
|
+
"""Load and return ``(image, mask)`` for the given index.
|
|
227
|
+
|
|
228
|
+
With torch installed, returns:
|
|
229
|
+
- ``image``: float32 tensor, shape ``(C, H, W)``, ImageNet-normalized.
|
|
230
|
+
- ``mask``: long tensor, shape ``(H, W)``, values in ``{0, 1}``.
|
|
231
|
+
|
|
232
|
+
Without torch, returns numpy arrays:
|
|
233
|
+
- ``image``: float32 ndarray, shape ``(H, W, C)``, in ``[0, 1]``.
|
|
234
|
+
- ``mask``: uint8 ndarray, shape ``(H, W)``, values in ``{0, 1}``.
|
|
235
|
+
"""
|
|
236
|
+
stem = self.stems[index]
|
|
237
|
+
|
|
238
|
+
img_dir, mask_dir = self._stem_dirs.get(stem, (self._image_dir, self._mask_dir))
|
|
239
|
+
image = load_spectral_false_color(
|
|
240
|
+
img_dir / f"{stem}.npy", self.config.false_color, self.manifest
|
|
241
|
+
)
|
|
242
|
+
if self.config.pixel_shift != 0.0:
|
|
243
|
+
image = shift_image(image, self.config.pixel_shift)
|
|
244
|
+
if self.config.mask_type == "probability":
|
|
245
|
+
mask = np.load(mask_dir / f"{stem}.npy")
|
|
246
|
+
else:
|
|
247
|
+
mask = load_mask(mask_dir / f"{stem}.png")
|
|
248
|
+
mask = binarize_mask(mask)
|
|
249
|
+
|
|
250
|
+
assert image.shape[:2] == mask.shape, (
|
|
251
|
+
f"spatial mismatch at native resolution: image {image.shape[:2]} vs mask {mask.shape}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Augmentation runs at native resolution on BOTH image and mask so they
|
|
255
|
+
# stay aligned and the mask is never upscaled by interpolation.
|
|
256
|
+
if self._aug_transform is not None:
|
|
257
|
+
self._reseed_augmentation_in_worker()
|
|
258
|
+
transformed = self._aug_transform(image=image, mask=mask)
|
|
259
|
+
image = transformed["image"]
|
|
260
|
+
mask = transformed["mask"]
|
|
261
|
+
|
|
262
|
+
# The image alone is resized to image_size; the mask stays native.
|
|
263
|
+
if self._resize_transform is not None:
|
|
264
|
+
image = self._resize_transform(image=image)["image"]
|
|
265
|
+
|
|
266
|
+
if self.config.mask_type != "probability":
|
|
267
|
+
validate_binary_mask(mask)
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
import torch as _ # noqa: F401
|
|
271
|
+
except ImportError:
|
|
272
|
+
return to_numpy(image, mask)
|
|
273
|
+
if self.config.mask_type == "probability":
|
|
274
|
+
return to_tensors_soft(image, mask, self.config.normalize)
|
|
275
|
+
return to_tensors(image, mask, self.config.normalize)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Differentiable training losses for segmentation."""
|
|
2
|
+
|
|
3
|
+
from deepcontrail.segmentation.losses.config import LossConfig
|
|
4
|
+
from deepcontrail.segmentation.losses.losses import (
|
|
5
|
+
BCELoss,
|
|
6
|
+
CompositeLoss,
|
|
7
|
+
DiceLoss,
|
|
8
|
+
FocalLoss,
|
|
9
|
+
TverskyLoss,
|
|
10
|
+
build_loss,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BCELoss",
|
|
15
|
+
"CompositeLoss",
|
|
16
|
+
"DiceLoss",
|
|
17
|
+
"FocalLoss",
|
|
18
|
+
"LossConfig",
|
|
19
|
+
"TverskyLoss",
|
|
20
|
+
"build_loss",
|
|
21
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Pydantic configuration model for loss functions.
|
|
2
|
+
|
|
3
|
+
Serializable via ``model_dump()`` for MLflow logging.
|
|
4
|
+
Uses only pydantic — no torch dependency.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Annotated, Any, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
12
|
+
|
|
13
|
+
COMPOSITE_COMPONENTS = ("dice", "tversky", "bce", "focal")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LossConfig(BaseModel):
|
|
17
|
+
"""Loss function configuration for training.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
name
|
|
22
|
+
Loss function name: ``"dice"``, ``"tversky"``, ``"bce"``,
|
|
23
|
+
``"focal"``, or ``"composite"``.
|
|
24
|
+
smooth
|
|
25
|
+
Smoothing factor of the Dice loss. The Tversky loss always uses 0.
|
|
26
|
+
alpha
|
|
27
|
+
Tversky alpha (weight for false positives).
|
|
28
|
+
beta
|
|
29
|
+
Tversky beta (weight for false negatives).
|
|
30
|
+
gamma
|
|
31
|
+
Tversky focusing parameter.
|
|
32
|
+
from_logits
|
|
33
|
+
Whether the Dice and Tversky losses receive raw logits (sigmoid applied internally). The
|
|
34
|
+
BCE and focal losses always take logits.
|
|
35
|
+
composite_weights
|
|
36
|
+
Weights of the composite loss components, among ``"dice"``, ``"tversky"``, ``"bce"`` and
|
|
37
|
+
``"focal"``: none negative, at least one positive.
|
|
38
|
+
Keys may be ``"dice"``, ``"tversky"``, ``"bce"``, ``"focal"``.
|
|
39
|
+
Only used when ``name="composite"``.
|
|
40
|
+
pos_weight
|
|
41
|
+
BCE positive-class weight. Set to ``num_bg / num_fg`` to compensate
|
|
42
|
+
for class imbalance (e.g. ``175.0`` for 0.567 % foreground).
|
|
43
|
+
``None`` means no weighting.
|
|
44
|
+
smooth_factor
|
|
45
|
+
BCE label-smoothing factor. ``None`` means no smoothing.
|
|
46
|
+
focal_gamma
|
|
47
|
+
Focal loss focusing parameter — down-weights easy (confident) examples.
|
|
48
|
+
``gamma=0`` is equivalent to BCE; ``gamma=2`` is the standard value.
|
|
49
|
+
focal_alpha
|
|
50
|
+
Focal loss class-balance factor. ``None`` means no balancing.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
54
|
+
|
|
55
|
+
name: Literal["dice", "tversky", "bce", "focal", "composite"] = "dice"
|
|
56
|
+
smooth: Annotated[float, Field(ge=0.0)] = 1.0
|
|
57
|
+
alpha: Annotated[float, Field(ge=0.0, le=1.0)] = 0.7
|
|
58
|
+
beta: Annotated[float, Field(ge=0.0, le=1.0)] = 0.3
|
|
59
|
+
gamma: Annotated[float, Field(gt=0.0)] = 1.0
|
|
60
|
+
from_logits: bool = True
|
|
61
|
+
composite_weights: dict[str, float] = {"dice": 0.5, "tversky": 0.5}
|
|
62
|
+
pos_weight: float | None = None
|
|
63
|
+
smooth_factor: float | None = None
|
|
64
|
+
focal_gamma: Annotated[float, Field(ge=0.0)] = 2.0
|
|
65
|
+
focal_alpha: float | None = None
|
|
66
|
+
|
|
67
|
+
@model_validator(mode="after")
|
|
68
|
+
def _check_composite_weights(self) -> LossConfig:
|
|
69
|
+
"""Composite components must be known, non-negative, and at least one positive."""
|
|
70
|
+
unknown = sorted(set(self.composite_weights) - set(COMPOSITE_COMPONENTS))
|
|
71
|
+
if unknown:
|
|
72
|
+
expected = list(COMPOSITE_COMPONENTS)
|
|
73
|
+
msg = f"unknown composite_weights {unknown}; expected some of {expected}"
|
|
74
|
+
raise ValueError(msg)
|
|
75
|
+
negative = sorted(k for k, w in self.composite_weights.items() if w < 0)
|
|
76
|
+
if negative:
|
|
77
|
+
raise ValueError(f"composite_weights must not be negative: {negative}")
|
|
78
|
+
if self.name == "composite" and not any(w > 0 for w in self.composite_weights.values()):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"a composite loss needs at least one positive weight in composite_weights"
|
|
81
|
+
)
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_dict(cls, d: dict[str, Any]) -> LossConfig:
|
|
86
|
+
"""Build a ``LossConfig`` from a plain dictionary."""
|
|
87
|
+
return cls.model_validate(d)
|