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.
@@ -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)