deepcontrail-core 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,49 @@
1
+ """Shared, torch-free foundations: dataset format, config, composites, splits, metrics."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from deepcontrail.core.config import DatasetConfig
6
+ from deepcontrail.core.contract import (
7
+ BandChannel,
8
+ BandManifest,
9
+ DatasetContractError,
10
+ DatasetInfo,
11
+ read_band_manifest,
12
+ read_dataset_info,
13
+ )
14
+ from deepcontrail.core.false_color import (
15
+ FalseColorChannel,
16
+ FalseColorConfig,
17
+ check_false_color,
18
+ false_color_from_spectral,
19
+ load_spectral_false_color,
20
+ )
21
+ from deepcontrail.core.metrics import dice_coefficient, iou_score
22
+ from deepcontrail.core.splits import context_frames, partition_stems, split_fingerprint
23
+ from deepcontrail.core.validate import Issue, ValidationReport, validate_dataset
24
+
25
+ __version__ = version("deepcontrail-core")
26
+
27
+ __all__ = [
28
+ "BandChannel",
29
+ "BandManifest",
30
+ "DatasetConfig",
31
+ "DatasetContractError",
32
+ "DatasetInfo",
33
+ "FalseColorChannel",
34
+ "FalseColorConfig",
35
+ "Issue",
36
+ "ValidationReport",
37
+ "__version__",
38
+ "check_false_color",
39
+ "context_frames",
40
+ "dice_coefficient",
41
+ "false_color_from_spectral",
42
+ "iou_score",
43
+ "load_spectral_false_color",
44
+ "partition_stems",
45
+ "read_band_manifest",
46
+ "read_dataset_info",
47
+ "split_fingerprint",
48
+ "validate_dataset",
49
+ ]
@@ -0,0 +1,89 @@
1
+ """Pydantic configuration of how scenes are read from a dataset root.
2
+
3
+ Serializable via ``model_dump()`` → YAML/JSON for experiment logging.
4
+ Uses only pydantic + pyyaml so that ``import deepcontrail.core`` stays torch-free.
5
+ Task packages extend ``DatasetConfig`` with their own loader fields.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Annotated, Any, Literal, Self
12
+
13
+ import yaml
14
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
15
+
16
+ from deepcontrail.core.false_color import FalseColorConfig # noqa: TC001 (pydantic field)
17
+
18
+
19
+ class DatasetConfig(BaseModel):
20
+ """Which scenes to read from a dataset root, and how to build their images.
21
+
22
+ Parameters
23
+ ----------
24
+ root
25
+ Path to a dataset root in the DeepContrail format (``spectral_bands/``
26
+ with its ``bands.json``, label directories, ``metadata/``).
27
+ split
28
+ ``"train"``, ``"val"``, or ``"test"``. Both ``"train"`` and ``"val"``
29
+ read from the ``train/`` directory on disk; the dataset partitions stems
30
+ deterministically using ``val_fraction``. ``"test"`` reads from ``test/``.
31
+ false_color
32
+ Bands and bounds of the composite built from ``spectral_bands/``, by the
33
+ band keys of the dataset's ``bands.json``. Required: there is no default.
34
+ pixel_shift
35
+ Sub-pixel translation (right and down) applied to images at load time to
36
+ align them with the masks. Use the dataset's ``recommended_pixel_shift``
37
+ (``metadata/dataset_info.json``); default 0.0 (no shift).
38
+ seed
39
+ RNG seed for the train/val partition and for reproducible augmentation.
40
+ val_fraction
41
+ Fraction of training stems held out for validation (seeded by ``seed``).
42
+ Must be > 0 when ``fold`` is not set. Ignored when ``fold`` is set
43
+ (fold partitioning takes over).
44
+ fold
45
+ K-fold cross-validation fold index, or ``None`` to disable CV.
46
+ num_folds
47
+ Total number of CV folds. Only used when ``fold`` is set.
48
+ """
49
+
50
+ model_config = ConfigDict(frozen=True)
51
+
52
+ root: str
53
+ split: Literal["train", "val", "test"] = "train"
54
+ false_color: FalseColorConfig
55
+ pixel_shift: Annotated[float, Field(ge=0.0)] = 0.0
56
+ seed: Annotated[int, Field(ge=0)] = 42
57
+ val_fraction: Annotated[float, Field(default=0.1, ge=0.0, lt=1.0)] = 0.1
58
+ fold: int | None = None
59
+ num_folds: Annotated[int, Field(default=4, ge=2)] = 4
60
+
61
+ @model_validator(mode="after")
62
+ def _validate_split_strategy(self) -> Self:
63
+ """Validate the split configuration.
64
+
65
+ - ``fold`` must be in ``[0, num_folds)`` when set.
66
+ - ``val_fraction`` must be > 0 when ``fold`` is not set.
67
+ """
68
+ if self.fold is not None and not (0 <= self.fold < self.num_folds):
69
+ msg = f"fold {self.fold} out of range [0, {self.num_folds})"
70
+ raise ValueError(msg)
71
+ if self.val_fraction <= 0 and self.fold is None:
72
+ msg = "val_fraction must be > 0 (use fold for CV instead)"
73
+ raise ValueError(msg)
74
+ return self
75
+
76
+ @classmethod
77
+ def from_dict(cls, d: dict[str, Any]) -> Self:
78
+ """Build a config from a plain dictionary.
79
+
80
+ Pydantic handles nested dict → sub-model parsing automatically.
81
+ """
82
+ return cls.model_validate(d)
83
+
84
+ @classmethod
85
+ def from_yaml(cls, path: str | Path) -> Self:
86
+ """Load a config from the ``data:`` section of a YAML file."""
87
+ with Path(path).open(encoding="utf-8") as f:
88
+ raw = yaml.safe_load(f)
89
+ return cls.model_validate(raw["data"])
@@ -0,0 +1,196 @@
1
+ """DeepContrail dataset format, version 1: the metadata every dataset root carries.
2
+
3
+ A dataset is any directory that follows the format; the OpenContrails converter is one
4
+ producer. Two JSON files describe it:
5
+
6
+ - ``spectral_bands/bands.json`` (``BandManifest``): the channel order of every
7
+ ``spectral_bands/{split}/<id>.npy`` file, with the key configs use for each channel.
8
+ - ``metadata/dataset_info.json`` (``DatasetInfo``): where the data comes from, how many
9
+ labelled scenes each split holds, which labels exist and the recommended pixel shift.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Annotated, Literal, Self, TypeVar
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
19
+
20
+ FORMAT_VERSION = 1
21
+ BAND_MANIFEST_PATH = "spectral_bands/bands.json"
22
+ DATASET_INFO_PATH = "metadata/dataset_info.json"
23
+
24
+ _Schema = TypeVar("_Schema", bound=BaseModel)
25
+
26
+ Label = Literal["semantic", "probability", "annotator", "instance"]
27
+ """A label directory: ``semantic_masks/``, ``probability_masks/``, ``annotator_masks/``,
28
+ ``instance_masks/``."""
29
+
30
+
31
+ class DatasetContractError(ValueError):
32
+ """A dataset root does not follow the DeepContrail dataset format."""
33
+
34
+
35
+ class BandChannel(BaseModel):
36
+ """One channel (last axis) of the ``spectral_bands/`` arrays.
37
+
38
+ Parameters
39
+ ----------
40
+ index
41
+ 0-based position on the last axis.
42
+ key
43
+ Name configs use to refer to the channel (``[a-z0-9_]+``).
44
+ units
45
+ Physical units, e.g. ``"K"`` for brightness temperatures.
46
+ sensor_band
47
+ The sensor's own band ID (``"13"`` for ABI, ``"IR10.5"`` for FCI). Never
48
+ interpreted by the library; it lets people check a recipe.
49
+ wavelength_um
50
+ Central wavelength in µm.
51
+ name
52
+ Human-readable description.
53
+ derived_from
54
+ ``(a, b)`` when the channel is stored as ``a - b`` of two other channels.
55
+ """
56
+
57
+ model_config = ConfigDict(frozen=True, extra="forbid")
58
+
59
+ index: Annotated[int, Field(ge=0)]
60
+ key: Annotated[str, Field(pattern=r"^[a-z0-9_]+$")]
61
+ units: str
62
+ sensor_band: str | None = None
63
+ wavelength_um: float | None = None
64
+ name: str | None = None
65
+ derived_from: tuple[str, str] | None = None
66
+
67
+
68
+ class BandManifest(BaseModel):
69
+ """``spectral_bands/bands.json``: what each channel of the spectral arrays holds."""
70
+
71
+ model_config = ConfigDict(frozen=True, extra="forbid")
72
+
73
+ format_version: Literal[1]
74
+ sensor: str
75
+ dtype: Literal["float32"]
76
+ channels: Annotated[tuple[BandChannel, ...], Field(min_length=1)]
77
+ description: str | None = None
78
+
79
+ @property
80
+ def keys(self) -> tuple[str, ...]:
81
+ """Channel keys in channel order."""
82
+ return tuple(c.key for c in self.channels)
83
+
84
+ def index_of(self, key: str) -> int:
85
+ """Return the channel index of ``key``.
86
+
87
+ Raises
88
+ ------
89
+ DatasetContractError
90
+ If no channel has that key; the message lists the available keys.
91
+ """
92
+ try:
93
+ return self.keys.index(key)
94
+ except ValueError:
95
+ msg = f"unknown band key {key!r}; available: {', '.join(self.keys)}"
96
+ raise DatasetContractError(msg) from None
97
+
98
+ @model_validator(mode="after")
99
+ def _validate_channels(self) -> Self:
100
+ keys = [c.key for c in self.channels]
101
+ duplicates = sorted({k for k in keys if keys.count(k) > 1})
102
+ if duplicates:
103
+ msg = f"duplicate channel keys: {duplicates}"
104
+ raise ValueError(msg)
105
+ for position, channel in enumerate(self.channels):
106
+ if channel.index != position:
107
+ msg = f"channel {channel.key!r} has index {channel.index}, expected {position}"
108
+ raise ValueError(msg)
109
+ unknown = [k for k in channel.derived_from or () if k not in keys]
110
+ if unknown:
111
+ msg = f"channel {channel.key!r}: derived_from refers to unknown keys {unknown}"
112
+ raise ValueError(msg)
113
+ return self
114
+
115
+
116
+ class DatasetInfo(BaseModel):
117
+ """``metadata/dataset_info.json``: provenance, record counts, labels and pixel shift.
118
+
119
+ Parameters
120
+ ----------
121
+ source
122
+ What produced the data.
123
+ records
124
+ Number of labelled scenes per split.
125
+ labels
126
+ Label directories present in the dataset.
127
+ recommended_pixel_shift
128
+ Shift in pixels (right and down) that aligns the images with the masks;
129
+ ``0.0`` when they are aligned, ``None`` when unknown.
130
+ temporal
131
+ Whether the dataset stores context frames, ``<id>_b<k>`` / ``<id>_a<k>`` next to scene
132
+ ``<id>`` (see :func:`deepcontrail.core.splits.context_frames`); ``None`` means no.
133
+ export, naming, pixel_shift_note
134
+ Provenance notes for people; not read by the library.
135
+ """
136
+
137
+ model_config = ConfigDict(frozen=True, extra="forbid")
138
+
139
+ format_version: Literal[1]
140
+ source: str
141
+ records: dict[Literal["train", "test"], Annotated[int, Field(ge=0)]]
142
+ labels: tuple[Label, ...]
143
+ recommended_pixel_shift: Annotated[float, Field(ge=0.0)] | None
144
+ temporal: bool | None = None
145
+ export: str | None = None
146
+ naming: str | None = None
147
+ pixel_shift_note: str | None = None
148
+
149
+
150
+ def _read(root: str | Path, relative: str, schema: type[_Schema]) -> _Schema:
151
+ path = Path(root) / relative
152
+ if not path.is_file():
153
+ msg = f"{path}: missing; every dataset root needs {relative} (DeepContrail format v1)"
154
+ raise DatasetContractError(msg)
155
+ try:
156
+ payload = json.loads(path.read_text(encoding="utf-8"))
157
+ except UnicodeDecodeError as exc:
158
+ msg = f"{path}: must be UTF-8 encoded ({exc})"
159
+ raise DatasetContractError(msg) from exc
160
+ except json.JSONDecodeError as exc:
161
+ msg = f"{path}: not valid JSON ({exc})"
162
+ raise DatasetContractError(msg) from exc
163
+ if isinstance(payload, dict) and "format_version" not in payload:
164
+ msg = (
165
+ f"{path}: written before DeepContrail format v1 (no format_version). Regenerate the "
166
+ "metadata; for OpenContrails data: deepcontrail-convert-opencontrails "
167
+ "--metadata-only --output-dir <root>"
168
+ )
169
+ raise DatasetContractError(msg)
170
+ try:
171
+ return schema.model_validate(payload)
172
+ except ValidationError as exc:
173
+ msg = f"{path}: does not follow DeepContrail format v1:\n{exc}"
174
+ raise DatasetContractError(msg) from exc
175
+
176
+
177
+ def read_band_manifest(root: str | Path) -> BandManifest:
178
+ """Read and validate ``<root>/spectral_bands/bands.json``.
179
+
180
+ Raises
181
+ ------
182
+ DatasetContractError
183
+ If the file is missing, is not JSON, or does not follow format v1.
184
+ """
185
+ return _read(root, BAND_MANIFEST_PATH, BandManifest)
186
+
187
+
188
+ def read_dataset_info(root: str | Path) -> DatasetInfo:
189
+ """Read and validate ``<root>/metadata/dataset_info.json``.
190
+
191
+ Raises
192
+ ------
193
+ DatasetContractError
194
+ If the file is missing, is not JSON, or does not follow format v1.
195
+ """
196
+ return _read(root, DATASET_INFO_PATH, DatasetInfo)
@@ -0,0 +1,118 @@
1
+ """False-colour composites built from spectral bands named in the dataset's manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Annotated
6
+
7
+ import numpy as np
8
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
9
+
10
+ from deepcontrail.core.contract import DatasetContractError
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+ from deepcontrail.core.contract import BandManifest
16
+
17
+ BandKey = Annotated[str, Field(min_length=1)]
18
+
19
+
20
+ class FalseColorChannel(BaseModel):
21
+ """One composite channel: a band, or the difference of two bands, scaled by ``bounds``.
22
+
23
+ ``bands=(a,)`` gives ``a``; ``bands=(a, b)`` gives ``a - b``, where ``a`` and ``b``
24
+ are keys of the dataset's ``spectral_bands/bands.json``. The value is mapped
25
+ linearly from ``bounds = (lo, hi)`` to ``[0, 1]`` and clipped.
26
+ """
27
+
28
+ model_config = ConfigDict(frozen=True)
29
+
30
+ bands: tuple[BandKey] | tuple[BandKey, BandKey]
31
+ bounds: tuple[float, float]
32
+
33
+ @model_validator(mode="after")
34
+ def _validate_bounds(self) -> FalseColorChannel:
35
+ lo, hi = self.bounds
36
+ if not lo < hi:
37
+ msg = f"bounds must be increasing (lo < hi), got {self.bounds}"
38
+ raise ValueError(msg)
39
+ return self
40
+
41
+
42
+ class FalseColorConfig(BaseModel):
43
+ """Bands and bounds of the 3-channel composite built from ``spectral_bands/``.
44
+
45
+ There is no default recipe: band keys belong to the dataset, so the recipe is
46
+ always written in the config (e.g. the standard ash RGB in
47
+ ``configs/experiment.yaml``).
48
+ """
49
+
50
+ model_config = ConfigDict(frozen=True)
51
+
52
+ channels: tuple[FalseColorChannel, FalseColorChannel, FalseColorChannel]
53
+
54
+
55
+ def check_false_color(config: FalseColorConfig, manifest: BandManifest) -> None:
56
+ """Check that every band of the recipe is a key of the manifest.
57
+
58
+ Raises
59
+ ------
60
+ DatasetContractError
61
+ Naming every unknown key and listing the available ones.
62
+ """
63
+ unknown = [k for channel in config.channels for k in channel.bands if k not in manifest.keys]
64
+ if unknown:
65
+ msg = (
66
+ f"false_color uses band keys not in spectral_bands/bands.json: {unknown}; "
67
+ f"available: {', '.join(manifest.keys)}"
68
+ )
69
+ raise DatasetContractError(msg)
70
+
71
+
72
+ def false_color_from_spectral(
73
+ bands: np.ndarray, config: FalseColorConfig, manifest: BandManifest
74
+ ) -> np.ndarray:
75
+ """Build the 3-channel composite from a spectral array.
76
+
77
+ Parameters
78
+ ----------
79
+ bands
80
+ Spectral array with shape ``(H, W, C)``, channels in manifest order (the
81
+ ``spectral_bands/*.npy`` layout).
82
+ config
83
+ Bands and bounds of each output channel.
84
+ manifest
85
+ The dataset's band manifest, mapping each key to its channel.
86
+
87
+ Returns
88
+ -------
89
+ np.ndarray
90
+ Float32 ``(H, W, 3)`` array with values in ``[0, 1]``.
91
+
92
+ Raises
93
+ ------
94
+ ValueError
95
+ If ``bands`` is not ``(H, W, C)`` with the manifest's ``C`` channels.
96
+ DatasetContractError
97
+ If the recipe uses a key the manifest does not declare.
98
+ """
99
+ n = len(manifest.channels)
100
+ if bands.ndim != 3 or bands.shape[-1] != n:
101
+ msg = f"expected spectral bands of shape (H, W, {n}) ({n} channels), got {bands.shape}"
102
+ raise ValueError(msg)
103
+
104
+ channels = []
105
+ for channel in config.channels:
106
+ value = bands[..., manifest.index_of(channel.bands[0])]
107
+ if len(channel.bands) == 2:
108
+ value = value - bands[..., manifest.index_of(channel.bands[1])]
109
+ lo, hi = channel.bounds
110
+ channels.append((value - lo) / (hi - lo))
111
+ return np.clip(np.stack(channels, axis=-1), 0, 1).astype(np.float32)
112
+
113
+
114
+ def load_spectral_false_color(
115
+ path: str | Path, config: FalseColorConfig, manifest: BandManifest
116
+ ) -> np.ndarray:
117
+ """Load a ``spectral_bands/*.npy`` file and build its composite with ``config``."""
118
+ return false_color_from_spectral(np.load(path), config, manifest)
@@ -0,0 +1,113 @@
1
+ """Pure evaluation metrics for binary segmentation (no gradient, numpy-based)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import numpy as np
9
+
10
+ if TYPE_CHECKING:
11
+ from numpy.typing import ArrayLike
12
+
13
+
14
+ def _to_numpy(x: object) -> np.ndarray:
15
+ """Convert input to a flat float32 numpy array, handling torch tensors safely.
16
+
17
+ float32 is enough for binary {0, 1} mask metrics; it halves the host-side
18
+ memory footprint when full val/test sets are concatenated.
19
+ """
20
+ # A tensor can only exist if torch is already imported: look it up instead of
21
+ # importing it, so numpy-only callers never load torch (deepcontrail-core has no torch).
22
+ torch = sys.modules.get("torch")
23
+ if torch is not None and isinstance(x, torch.Tensor):
24
+ tensor: Any = x
25
+ return tensor.detach().cpu().numpy().astype(np.float32).ravel()
26
+ return np.asarray(x, dtype=np.float32).ravel()
27
+
28
+
29
+ def _check_binary_pair(pred: np.ndarray, target: np.ndarray) -> None:
30
+ """Raise ``ValueError`` unless both masks have the same size and hold only 0 and 1."""
31
+ if pred.size != target.size:
32
+ msg = f"inputs must have the same number of elements; got {pred.size} and {target.size}"
33
+ raise ValueError(msg)
34
+ for name, values in (("pred", pred), ("target", target)):
35
+ # Elementwise test: no sort, so cheap on whole validation sets.
36
+ if not ((values == 0) | (values == 1)).all():
37
+ bad = values[(values != 0) & (values != 1)]
38
+ msg = f"{name} must contain only 0 and 1, got e.g. {bad[:5].tolist()}"
39
+ raise ValueError(msg)
40
+
41
+
42
+ def dice_coefficient(pred: ArrayLike, target: ArrayLike, smooth: float = 1e-6) -> float:
43
+ """Compute the Dice coefficient between binary masks.
44
+
45
+ Parameters
46
+ ----------
47
+ pred
48
+ Predicted binary mask (values in {0, 1}, boolean accepted). Any shape.
49
+ target
50
+ Ground-truth binary mask (values in {0, 1}, boolean accepted). Same number
51
+ of elements as ``pred``.
52
+ smooth
53
+ Smoothing constant to avoid division by zero.
54
+
55
+ Returns
56
+ -------
57
+ float
58
+ Dice coefficient in [0, 1], pooled over all elements (one global score,
59
+ not a per-image mean). Returns 1.0 when both masks are empty (all zeros).
60
+
61
+ Raises
62
+ ------
63
+ ValueError
64
+ If ``pred`` and ``target`` have different numbers of elements, or if either
65
+ contains values outside {0, 1}.
66
+ """
67
+ pred_flat = _to_numpy(pred)
68
+ target_flat = _to_numpy(target)
69
+
70
+ _check_binary_pair(pred_flat, target_flat)
71
+
72
+ if pred_flat.sum() == 0 and target_flat.sum() == 0:
73
+ return 1.0
74
+
75
+ intersection = (pred_flat * target_flat).sum()
76
+ return float((2.0 * intersection + smooth) / (pred_flat.sum() + target_flat.sum() + smooth))
77
+
78
+
79
+ def iou_score(pred: ArrayLike, target: ArrayLike, smooth: float = 1e-6) -> float:
80
+ """Compute the Intersection over Union (Jaccard index) between binary masks.
81
+
82
+ Parameters
83
+ ----------
84
+ pred
85
+ Predicted binary mask (values in {0, 1}, boolean accepted).
86
+ target
87
+ Ground-truth binary mask (values in {0, 1}, boolean accepted).
88
+ smooth
89
+ Smoothing constant.
90
+
91
+ Returns
92
+ -------
93
+ float
94
+ IoU score in [0, 1], pooled over all elements (one global score, not a
95
+ per-image mean). Returns 1.0 when both masks are empty (all zeros).
96
+
97
+ Raises
98
+ ------
99
+ ValueError
100
+ If ``pred`` and ``target`` have different numbers of elements, or if either
101
+ contains values outside {0, 1}.
102
+ """
103
+ pred_flat = _to_numpy(pred)
104
+ target_flat = _to_numpy(target)
105
+
106
+ _check_binary_pair(pred_flat, target_flat)
107
+
108
+ if pred_flat.sum() == 0 and target_flat.sum() == 0:
109
+ return 1.0
110
+
111
+ intersection = (pred_flat * target_flat).sum()
112
+ union = pred_flat.sum() + target_flat.sum() - intersection
113
+ return float((intersection + smooth) / (union + smooth))
File without changes