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.
- deepcontrail/core/__init__.py +49 -0
- deepcontrail/core/config.py +89 -0
- deepcontrail/core/contract.py +196 -0
- deepcontrail/core/false_color.py +118 -0
- deepcontrail/core/metrics.py +113 -0
- deepcontrail/core/py.typed +0 -0
- deepcontrail/core/readers.py +209 -0
- deepcontrail/core/splits.py +93 -0
- deepcontrail/core/validate.py +348 -0
- deepcontrail_core-0.1.0.dist-info/METADATA +149 -0
- deepcontrail_core-0.1.0.dist-info/RECORD +14 -0
- deepcontrail_core-0.1.0.dist-info/WHEEL +4 -0
- deepcontrail_core-0.1.0.dist-info/entry_points.txt +3 -0
- deepcontrail_core-0.1.0.dist-info/licenses/LICENSE +211 -0
|
@@ -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
|