deepcontrail-instance 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/instance/__init__.py +49 -0
- deepcontrail/instance/_evaluation.py +392 -0
- deepcontrail/instance/_growing.py +236 -0
- deepcontrail/instance/_lines.py +158 -0
- deepcontrail/instance/_overlap.py +77 -0
- deepcontrail/instance/_shape.py +190 -0
- deepcontrail/instance/_tuning.py +97 -0
- deepcontrail/instance/bundle.py +116 -0
- deepcontrail/instance/config.py +237 -0
- deepcontrail/instance/io.py +139 -0
- deepcontrail/instance/labels.py +211 -0
- deepcontrail/instance/mannstein.py +114 -0
- deepcontrail/instance/metrics.py +232 -0
- deepcontrail/instance/pipeline.py +90 -0
- deepcontrail/instance/py.typed +0 -0
- deepcontrail/instance/refine.py +119 -0
- deepcontrail/instance/scoring.py +405 -0
- deepcontrail_instance-0.1.0.dist-info/METADATA +219 -0
- deepcontrail_instance-0.1.0.dist-info/RECORD +21 -0
- deepcontrail_instance-0.1.0.dist-info/WHEEL +4 -0
- deepcontrail_instance-0.1.0.dist-info/licenses/LICENSE +211 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Learned contrail scores: re-rank detected contrails with a model trained on your data.
|
|
2
|
+
|
|
3
|
+
The pipeline's scores (Mannstein's straightness and size, times the mean probability) rank
|
|
4
|
+
detections only roughly. A :class:`ScoreModel` predicts, from a few features of each detected
|
|
5
|
+
contrail (:data:`FEATURES`), how well it matches the annotated contrails, and that prediction
|
|
6
|
+
replaces its score. The masks do not change, so only the ranking (and the mAP) changes.
|
|
7
|
+
|
|
8
|
+
The features depend on the semantic model's probabilities, so a score model belongs to one
|
|
9
|
+
semantic model and one pipeline config: train it on that model's predictions for scenes it was
|
|
10
|
+
not trained on (e.g. its validation split), with :func:`training_data` and
|
|
11
|
+
:meth:`ScoreModel.fit`. Fitting uses scikit-learn's gradient boosting; the fitted trees are
|
|
12
|
+
exported to plain JSON, so using a model needs numpy only and loading a file runs no code.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from importlib.metadata import version
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
from scipy.ndimage import binary_dilation
|
|
25
|
+
|
|
26
|
+
from deepcontrail.instance.config import InstancePipelineConfig
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
|
|
31
|
+
FEATURES: tuple[str, ...] = (
|
|
32
|
+
"n_pixels",
|
|
33
|
+
"length",
|
|
34
|
+
"width",
|
|
35
|
+
"straightness",
|
|
36
|
+
"mean_prob",
|
|
37
|
+
"max_prob",
|
|
38
|
+
"min_prob",
|
|
39
|
+
"std_prob",
|
|
40
|
+
"p10_prob",
|
|
41
|
+
"p90_prob",
|
|
42
|
+
"frac_above_0_7",
|
|
43
|
+
"ring_mean_prob",
|
|
44
|
+
"contrast",
|
|
45
|
+
"pipeline_score",
|
|
46
|
+
"n_scene_instances",
|
|
47
|
+
"shared_pixels_frac",
|
|
48
|
+
"scene_pred_area",
|
|
49
|
+
"border_distance",
|
|
50
|
+
"orientation_sin2",
|
|
51
|
+
"orientation_cos2",
|
|
52
|
+
)
|
|
53
|
+
"""Names of the features of one detected contrail, in the order of :func:`instance_features`."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def instance_features(
|
|
57
|
+
mask: np.ndarray, probs: np.ndarray, score: float, scene_masks: Sequence[np.ndarray]
|
|
58
|
+
) -> np.ndarray:
|
|
59
|
+
"""Describe one detected contrail (size, shape, probabilities, surroundings).
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
mask
|
|
64
|
+
Boolean ``(H, W)`` mask of the contrail.
|
|
65
|
+
probs
|
|
66
|
+
The scene's probability map ``(H, W)``.
|
|
67
|
+
score
|
|
68
|
+
The contrail's score from the pipeline.
|
|
69
|
+
scene_masks
|
|
70
|
+
All contrails detected in the scene, ``mask`` included.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
np.ndarray
|
|
75
|
+
One value per name in :data:`FEATURES`.
|
|
76
|
+
"""
|
|
77
|
+
# A 0/1 int mask indexes ``probs`` by position and ``~mask`` is a bitwise not, so compute on a
|
|
78
|
+
# bool copy. But keep the identity test below on the original objects: a bool copy is no longer
|
|
79
|
+
# ``is`` the mask in scene_masks, and the contrail would then count as overlapping itself.
|
|
80
|
+
bool_mask = np.asarray(mask, dtype=bool)
|
|
81
|
+
ys, xs = np.nonzero(bool_mask)
|
|
82
|
+
n = len(ys)
|
|
83
|
+
values = probs[bool_mask]
|
|
84
|
+
coords = np.stack([ys, xs], axis=1).astype(float)
|
|
85
|
+
centre = coords.mean(axis=0)
|
|
86
|
+
if n >= 2:
|
|
87
|
+
eigvals, eigvecs = np.linalg.eigh(np.cov((coords - centre).T))
|
|
88
|
+
along = (coords - centre) @ eigvecs[:, -1]
|
|
89
|
+
length = float(along.max() - along.min() + 1.0)
|
|
90
|
+
straightness = float(1.0 - eigvals[0] / eigvals[-1]) if eigvals[-1] > 0 else 0.0
|
|
91
|
+
angle = float(np.arctan2(eigvecs[0, -1], eigvecs[1, -1]))
|
|
92
|
+
else:
|
|
93
|
+
length, straightness, angle = float(n), 0.0, 0.0
|
|
94
|
+
ring = binary_dilation(bool_mask, np.ones((3, 3), dtype=bool)) & ~bool_mask
|
|
95
|
+
ring_mean = float(probs[ring].mean()) if ring.any() else 0.0
|
|
96
|
+
others = [np.asarray(m, dtype=bool) for m in scene_masks if m is not mask]
|
|
97
|
+
shared = (
|
|
98
|
+
float(np.logical_and(bool_mask, np.any(np.stack(others), axis=0)).sum() / n)
|
|
99
|
+
if others
|
|
100
|
+
else 0.0
|
|
101
|
+
)
|
|
102
|
+
h, w = bool_mask.shape
|
|
103
|
+
border = min(centre[0], centre[1], h - 1 - centre[0], w - 1 - centre[1]) / max(h, w)
|
|
104
|
+
union_area = (
|
|
105
|
+
float(np.any(np.stack([np.asarray(m, dtype=bool) for m in scene_masks]), axis=0).sum())
|
|
106
|
+
if scene_masks
|
|
107
|
+
else 0.0
|
|
108
|
+
)
|
|
109
|
+
return np.array(
|
|
110
|
+
[
|
|
111
|
+
n,
|
|
112
|
+
length,
|
|
113
|
+
n / length if length else 0.0,
|
|
114
|
+
straightness,
|
|
115
|
+
values.mean(),
|
|
116
|
+
values.max(),
|
|
117
|
+
values.min(),
|
|
118
|
+
values.std(),
|
|
119
|
+
np.percentile(values, 10),
|
|
120
|
+
np.percentile(values, 90),
|
|
121
|
+
np.mean(values > 0.7),
|
|
122
|
+
ring_mean,
|
|
123
|
+
values.mean() - ring_mean,
|
|
124
|
+
score,
|
|
125
|
+
len(scene_masks),
|
|
126
|
+
shared,
|
|
127
|
+
union_area,
|
|
128
|
+
border,
|
|
129
|
+
np.sin(2 * angle),
|
|
130
|
+
np.cos(2 * angle),
|
|
131
|
+
],
|
|
132
|
+
dtype=float,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def scene_features(
|
|
137
|
+
masks: Sequence[np.ndarray], probs: np.ndarray, scores: Sequence[float]
|
|
138
|
+
) -> np.ndarray:
|
|
139
|
+
"""Return the ``(n_contrails, len(FEATURES))`` features of one scene's detections."""
|
|
140
|
+
if not masks:
|
|
141
|
+
return np.zeros((0, len(FEATURES)))
|
|
142
|
+
return np.stack(
|
|
143
|
+
[instance_features(m, probs, s, masks) for m, s in zip(masks, scores, strict=True)]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _best_iou(mask: np.ndarray, gts: Sequence[np.ndarray]) -> float:
|
|
148
|
+
best = 0.0
|
|
149
|
+
for g in gts:
|
|
150
|
+
union = np.logical_or(mask, g).sum()
|
|
151
|
+
if union:
|
|
152
|
+
best = max(best, float(np.logical_and(mask, g).sum() / union))
|
|
153
|
+
return best
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def match_quality(mask: np.ndarray, gts_per_annotator: Sequence[Sequence[np.ndarray]]) -> float:
|
|
157
|
+
"""Training label: mean best IoU of ``mask`` over the annotators who drew a contrail.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
mask
|
|
162
|
+
Boolean ``(H, W)`` mask of one detected contrail.
|
|
163
|
+
gts_per_annotator
|
|
164
|
+
For each annotator of the scene, the boolean masks of the contrails they drew.
|
|
165
|
+
|
|
166
|
+
Returns
|
|
167
|
+
-------
|
|
168
|
+
float
|
|
169
|
+
In ``[0, 1]``; 0.0 if no annotator drew anything.
|
|
170
|
+
"""
|
|
171
|
+
drawn = [g for g in gts_per_annotator if g]
|
|
172
|
+
return float(np.mean([_best_iou(mask, g) for g in drawn])) if drawn else 0.0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def training_data(
|
|
176
|
+
probs: Sequence[np.ndarray],
|
|
177
|
+
gts: Sequence[Sequence[Sequence[np.ndarray]]],
|
|
178
|
+
config: InstancePipelineConfig,
|
|
179
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
180
|
+
"""Run the pipeline on each scene and return features and labels of every detection.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
probs
|
|
185
|
+
Probability maps of the training scenes.
|
|
186
|
+
gts
|
|
187
|
+
``gts[i][a]`` holds the contrail masks drawn by annotator ``a`` on scene ``i``.
|
|
188
|
+
config
|
|
189
|
+
The pipeline config the score model will be used with.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
tuple[np.ndarray, np.ndarray]
|
|
194
|
+
Features ``(n, len(FEATURES))`` and labels ``(n,)`` (:func:`match_quality`).
|
|
195
|
+
"""
|
|
196
|
+
from deepcontrail.instance.pipeline import detect_instances_from_probabilities
|
|
197
|
+
|
|
198
|
+
rows, labels = [], []
|
|
199
|
+
for scene_probs, scene_gts in zip(probs, gts, strict=True):
|
|
200
|
+
p = np.asarray(scene_probs, dtype=np.float32)
|
|
201
|
+
masks, scores = detect_instances_from_probabilities(p, config)
|
|
202
|
+
rows.append(scene_features(masks, p, scores))
|
|
203
|
+
labels.extend(match_quality(m, scene_gts) for m in masks)
|
|
204
|
+
x = np.concatenate(rows) if rows else np.zeros((0, len(FEATURES)))
|
|
205
|
+
return x, np.asarray(labels, dtype=float)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
_NODE_FIELDS = ("feature_idx", "num_threshold", "missing_go_to_left", "left", "right", "is_leaf")
|
|
209
|
+
|
|
210
|
+
# The keys to_json writes per tree: _NODE_FIELDS plus the leaf values.
|
|
211
|
+
_TREE_KEYS = (*_NODE_FIELDS, "value")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _check_tree(index: int, tree: dict[str, np.ndarray]) -> None:
|
|
215
|
+
"""Reject a malformed tree before :meth:`ScoreModel.predict` walks it.
|
|
216
|
+
|
|
217
|
+
``predict`` follows ``left``/``right`` and indexes ``feature_idx`` without checking bounds or
|
|
218
|
+
cycles, so a corrupt file would loop forever (a child pointing at its parent), read the wrong
|
|
219
|
+
feature (a negative index), or fail mid-prediction (ragged or non-finite arrays). The
|
|
220
|
+
``left``/``right``/``feature_idx``/``num_threshold`` of leaves are unchecked scikit-learn
|
|
221
|
+
placeholders.
|
|
222
|
+
"""
|
|
223
|
+
expected_keys = set(_TREE_KEYS)
|
|
224
|
+
if set(tree) != expected_keys:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"score model tree {index}: keys {sorted(set(tree))} are not {sorted(expected_keys)}"
|
|
227
|
+
)
|
|
228
|
+
n = len(tree["value"])
|
|
229
|
+
lengths = [len(tree[key]) for key in expected_keys]
|
|
230
|
+
if n < 1 or any(length != n for length in lengths):
|
|
231
|
+
raise ValueError(
|
|
232
|
+
f"score model tree {index}: node arrays have lengths {lengths}, expected one >= 1"
|
|
233
|
+
)
|
|
234
|
+
is_leaf = tree["is_leaf"].astype(bool)
|
|
235
|
+
for j in np.nonzero(~is_leaf)[0]:
|
|
236
|
+
left, right = int(tree["left"][j]), int(tree["right"][j])
|
|
237
|
+
if not (j < left < n and j < right < n):
|
|
238
|
+
raise ValueError(
|
|
239
|
+
f"score model tree {index}: node {j} children {left}, {right} must come after it "
|
|
240
|
+
f"and within its {n} nodes"
|
|
241
|
+
)
|
|
242
|
+
if not (0 <= int(tree["feature_idx"][j]) < len(FEATURES)):
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"score model tree {index}: node {j} feature index {int(tree['feature_idx'][j])} "
|
|
245
|
+
f"is out of range for {len(FEATURES)} features"
|
|
246
|
+
)
|
|
247
|
+
if not np.isfinite(tree["num_threshold"][j]):
|
|
248
|
+
raise ValueError(
|
|
249
|
+
f"score model tree {index}: node {j} threshold {float(tree['num_threshold'][j])} "
|
|
250
|
+
f"is not finite"
|
|
251
|
+
)
|
|
252
|
+
if not np.all(np.isfinite(tree["value"])):
|
|
253
|
+
raise ValueError(f"score model tree {index}: leaf values are not all finite")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True)
|
|
257
|
+
class ScoreModel:
|
|
258
|
+
"""Gradient-boosted trees that predict a detected contrail's match quality.
|
|
259
|
+
|
|
260
|
+
Build one with :meth:`fit` (needs scikit-learn) or :meth:`from_json` (numpy only).
|
|
261
|
+
|
|
262
|
+
Attributes
|
|
263
|
+
----------
|
|
264
|
+
baseline
|
|
265
|
+
Constant start value of the prediction.
|
|
266
|
+
trees
|
|
267
|
+
One dict of node arrays per tree: ``feature_idx``, ``num_threshold``,
|
|
268
|
+
``missing_go_to_left``, ``left``, ``right``, ``is_leaf`` and ``value``.
|
|
269
|
+
pipeline_config
|
|
270
|
+
The pipeline config whose detections the model was trained on.
|
|
271
|
+
features
|
|
272
|
+
Feature names, checked on load against :data:`FEATURES`.
|
|
273
|
+
trained_on
|
|
274
|
+
What the model is valid for, since its features depend on the probability maps:
|
|
275
|
+
``semantic_model`` (and ``semantic_revision``), ``tta``, ``scene_shape`` ``[H, W]``,
|
|
276
|
+
plus the ``scikit_learn_version`` and ``deepcontrail_instance_version`` that fitted it.
|
|
277
|
+
``scene_shape`` is checked by the pipeline, ``tta`` by :func:`load_instance_step`.
|
|
278
|
+
Empty for files written before this record existed.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
baseline: float
|
|
282
|
+
trees: tuple[dict[str, np.ndarray], ...]
|
|
283
|
+
pipeline_config: InstancePipelineConfig
|
|
284
|
+
features: tuple[str, ...] = FEATURES
|
|
285
|
+
trained_on: dict[str, Any] = field(default_factory=dict)
|
|
286
|
+
|
|
287
|
+
FORMAT_VERSION: ClassVar[int] = 1
|
|
288
|
+
"""Version of the JSON layout; bump it when the features or the tree format change."""
|
|
289
|
+
|
|
290
|
+
ESTIMATOR_SETTINGS: ClassVar[dict[str, Any]] = {
|
|
291
|
+
"max_iter": 300,
|
|
292
|
+
"learning_rate": 0.05,
|
|
293
|
+
"max_leaf_nodes": 15,
|
|
294
|
+
"min_samples_leaf": 20,
|
|
295
|
+
"l2_regularization": 1.0,
|
|
296
|
+
"random_state": 0,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
@classmethod
|
|
300
|
+
def fit(
|
|
301
|
+
cls,
|
|
302
|
+
x: np.ndarray,
|
|
303
|
+
y: np.ndarray,
|
|
304
|
+
pipeline_config: InstancePipelineConfig,
|
|
305
|
+
trained_on: dict[str, Any] | None = None,
|
|
306
|
+
) -> ScoreModel:
|
|
307
|
+
"""Fit the trees on features ``x`` and labels ``y`` (fixed, seeded settings).
|
|
308
|
+
|
|
309
|
+
``trained_on`` describes the probability maps behind ``x`` (see the class attributes);
|
|
310
|
+
the scikit-learn and package versions are added to it.
|
|
311
|
+
"""
|
|
312
|
+
try:
|
|
313
|
+
# The [scores] extra; resolvable only where it is installed.
|
|
314
|
+
import sklearn.ensemble # pyright: ignore[reportMissingImports]
|
|
315
|
+
except ImportError as err:
|
|
316
|
+
raise ImportError(
|
|
317
|
+
"ScoreModel.fit needs scikit-learn: pip install 'deepcontrail-instance[scores]'"
|
|
318
|
+
) from err
|
|
319
|
+
|
|
320
|
+
estimator = sklearn.ensemble.HistGradientBoostingRegressor(**cls.ESTIMATOR_SETTINGS).fit(
|
|
321
|
+
x, y
|
|
322
|
+
)
|
|
323
|
+
trees = []
|
|
324
|
+
for (predictor,) in estimator._predictors:
|
|
325
|
+
nodes = predictor.nodes
|
|
326
|
+
tree = {name: np.asarray(nodes[name]) for name in _NODE_FIELDS}
|
|
327
|
+
tree["value"] = np.asarray(nodes["value"], dtype=float)
|
|
328
|
+
trees.append(tree)
|
|
329
|
+
baseline = float(np.ravel(estimator._baseline_prediction)[0])
|
|
330
|
+
record = {
|
|
331
|
+
**(trained_on or {}),
|
|
332
|
+
"scikit_learn_version": sklearn.__version__,
|
|
333
|
+
"deepcontrail_instance_version": version("deepcontrail-instance"),
|
|
334
|
+
}
|
|
335
|
+
return cls(baseline, tuple(trees), pipeline_config, trained_on=record)
|
|
336
|
+
|
|
337
|
+
def predict(self, x: np.ndarray) -> np.ndarray:
|
|
338
|
+
"""Predict the match quality of each row of ``x``, clipped to ``[0, 1]``.
|
|
339
|
+
|
|
340
|
+
Raises
|
|
341
|
+
------
|
|
342
|
+
ValueError
|
|
343
|
+
If ``x`` does not have one column per feature.
|
|
344
|
+
"""
|
|
345
|
+
x = np.asarray(x, dtype=float)
|
|
346
|
+
if x.ndim != 2 or x.shape[1] != len(self.features):
|
|
347
|
+
raise ValueError(f"expected {len(self.features)} features per row, got shape {x.shape}")
|
|
348
|
+
out = np.full(len(x), self.baseline)
|
|
349
|
+
for tree in self.trees:
|
|
350
|
+
node = np.zeros(len(x), dtype=int)
|
|
351
|
+
active = ~tree["is_leaf"][node].astype(bool)
|
|
352
|
+
while active.any():
|
|
353
|
+
idx = np.nonzero(active)[0]
|
|
354
|
+
current = node[idx]
|
|
355
|
+
values = x[idx, tree["feature_idx"][current]]
|
|
356
|
+
go_left = np.where(
|
|
357
|
+
np.isnan(values),
|
|
358
|
+
tree["missing_go_to_left"][current].astype(bool),
|
|
359
|
+
values <= tree["num_threshold"][current],
|
|
360
|
+
)
|
|
361
|
+
node[idx] = np.where(go_left, tree["left"][current], tree["right"][current])
|
|
362
|
+
active[idx] = ~tree["is_leaf"][node[idx]].astype(bool)
|
|
363
|
+
out += tree["value"][node]
|
|
364
|
+
return np.clip(out, 0.0, 1.0)
|
|
365
|
+
|
|
366
|
+
def to_json(self, path: str | Path) -> None:
|
|
367
|
+
"""Write the model as plain JSON."""
|
|
368
|
+
data = {
|
|
369
|
+
"format_version": self.FORMAT_VERSION,
|
|
370
|
+
"features": list(self.features),
|
|
371
|
+
"baseline": self.baseline,
|
|
372
|
+
"pipeline_config": self.pipeline_config.model_dump(),
|
|
373
|
+
"trained_on": self.trained_on,
|
|
374
|
+
"trees": [{k: v.tolist() for k, v in tree.items()} for tree in self.trees],
|
|
375
|
+
}
|
|
376
|
+
Path(path).write_text(json.dumps(data))
|
|
377
|
+
|
|
378
|
+
@classmethod
|
|
379
|
+
def from_json(cls, path: str | Path) -> ScoreModel:
|
|
380
|
+
"""Load a model written by :meth:`to_json`.
|
|
381
|
+
|
|
382
|
+
Raises
|
|
383
|
+
------
|
|
384
|
+
ValueError
|
|
385
|
+
If the file has another format version or other feature names.
|
|
386
|
+
"""
|
|
387
|
+
data = json.loads(Path(path).read_text())
|
|
388
|
+
if data.get("format_version") != cls.FORMAT_VERSION:
|
|
389
|
+
raise ValueError(
|
|
390
|
+
f"score model format version {data.get('format_version')!r} is not supported "
|
|
391
|
+
f"(expected {cls.FORMAT_VERSION}); retrain it with this library version"
|
|
392
|
+
)
|
|
393
|
+
if tuple(data["features"]) != FEATURES:
|
|
394
|
+
raise ValueError("score model features differ from this library's FEATURES")
|
|
395
|
+
trees = tuple({k: np.asarray(v) for k, v in tree.items()} for tree in data["trees"])
|
|
396
|
+
for i, tree in enumerate(trees):
|
|
397
|
+
_check_tree(i, tree)
|
|
398
|
+
config = InstancePipelineConfig.from_dict(data["pipeline_config"])
|
|
399
|
+
return cls(
|
|
400
|
+
float(data["baseline"]),
|
|
401
|
+
trees,
|
|
402
|
+
config,
|
|
403
|
+
tuple(data["features"]),
|
|
404
|
+
trained_on=dict(data.get("trained_on", {})),
|
|
405
|
+
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: deepcontrail-instance
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DeepContrail — instance segmentation of contrails: separate contrails in a segmentation map.
|
|
5
|
+
Keywords: contrails,instance-segmentation,remote-sensing,satellite,GOES
|
|
6
|
+
Author: EUROCONTROL
|
|
7
|
+
License-Expression: EUPL-1.2
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
15
|
+
Requires-Dist: deepcontrail-core>=0.1,<0.2
|
|
16
|
+
Requires-Dist: numpy>=1.24
|
|
17
|
+
Requires-Dist: scipy>=1.10
|
|
18
|
+
Requires-Dist: pydantic>=2.0
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Requires-Dist: pycocotools>=2.0 ; extra == 'map'
|
|
21
|
+
Requires-Dist: scikit-learn>=1.3,<1.10 ; extra == 'scores'
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Project-URL: Homepage, https://github.com/eurocontrol-asu/deepcontrail
|
|
24
|
+
Project-URL: Documentation, https://github.com/eurocontrol-asu/deepcontrail/tree/main/packages/instance#readme
|
|
25
|
+
Project-URL: Repository, https://github.com/eurocontrol-asu/deepcontrail
|
|
26
|
+
Project-URL: Issues, https://github.com/eurocontrol-asu/deepcontrail/issues
|
|
27
|
+
Project-URL: Changelog, https://github.com/eurocontrol-asu/deepcontrail/blob/main/CHANGELOG.md
|
|
28
|
+
Provides-Extra: map
|
|
29
|
+
Provides-Extra: scores
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# deepcontrail-instance
|
|
33
|
+
|
|
34
|
+
Instance segmentation of contrails, part of
|
|
35
|
+
[DeepContrail](https://github.com/eurocontrol-asu/deepcontrail): it splits a contrail
|
|
36
|
+
segmentation map into individual contrails, one mask and one score each.
|
|
37
|
+
|
|
38
|
+
**Status: preview.** The API may still change in the next releases.
|
|
39
|
+
|
|
40
|
+
It needs only `deepcontrail-core`, numpy, scipy, pydantic and PyYAML (no torch) and never imports
|
|
41
|
+
`deepcontrail.segmentation`: any model's probability map works as input. Two optional extras:
|
|
42
|
+
`[map]` (pycocotools) to save detections as COCO results, and `[scores]` (scikit-learn) to train a
|
|
43
|
+
score model.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install deepcontrail-instance # the base package
|
|
47
|
+
pip install "deepcontrail-instance[map,scores]" # with both extras
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import numpy as np
|
|
54
|
+
from deepcontrail.instance import detect_instances_from_probabilities
|
|
55
|
+
|
|
56
|
+
probs = np.load("scene_probs.npy") # (H, W) probabilities in [0, 1]
|
|
57
|
+
masks, scores = detect_instances_from_probabilities(probs)
|
|
58
|
+
# masks: one boolean (H, W) mask per contrail; scores: confidence in [0, 1]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The pipeline thresholds the map (at 0.465), splits the mask into straight contrails with a
|
|
62
|
+
Mannstein-style detection, grows each contrail by one pixel into neighbours with probability above
|
|
63
|
+
0.371 (annotators draw contrails wider than the thresholded mask), and weights each score by the
|
|
64
|
+
contrail's mean probability. Its settings live in `InstancePipelineConfig`; the defaults equal
|
|
65
|
+
[`configs/instance_pipeline.yaml`](https://github.com/eurocontrol-asu/deepcontrail/blob/main/packages/instance/configs/instance_pipeline.yaml), and
|
|
66
|
+
`InstancePipelineConfig.consensus()` equals
|
|
67
|
+
[`configs/instance_pipeline_consensus.yaml`](https://github.com/eurocontrol-asu/deepcontrail/blob/main/packages/instance/configs/instance_pipeline_consensus.yaml), for when
|
|
68
|
+
only contrails agreed by most annotators matter. `detect_instances(binary_mask)` runs plain
|
|
69
|
+
Mannstein on any binary mask (settings: `MannsteinConfig`, whose defaults are the pipeline's;
|
|
70
|
+
[`configs/mannstein_annotator_masks.yaml`](https://github.com/eurocontrol-asu/deepcontrail/blob/main/packages/instance/configs/mannstein_annotator_masks.yaml) holds the
|
|
71
|
+
settings for annotators' own masks).
|
|
72
|
+
|
|
73
|
+
Mannstein's oriented line filter (`MannsteinConfig(line_filter=True)`) is off in the shipped
|
|
74
|
+
settings: on these masks it kept every pixel and took most of the run time.
|
|
75
|
+
|
|
76
|
+
**Input.** A `(H, W)` map of probabilities in [0, 1] (after the sigmoid, not logits), on the same
|
|
77
|
+
pixel grid as the masks it is compared with. NaN or infinite values are rejected. A binary mask
|
|
78
|
+
also works as input: probability weighting then changes nothing, but growth can still add mask
|
|
79
|
+
pixels that no contrail took. For a plain binary mask, `detect_instances(mask)` runs the
|
|
80
|
+
Mannstein-style step alone.
|
|
81
|
+
|
|
82
|
+
**Known limits.**
|
|
83
|
+
- All settings are in pixels, tuned on GOES-16 / OpenContrails scenes (about 2 km per pixel,
|
|
84
|
+
contrails 1–3 px wide). Other resolutions need re-tuning.
|
|
85
|
+
- The cut (0.465) and the growth (into p > 0.371) were tuned on the validation predictions of
|
|
86
|
+
DeepContrail's earlier reference model (UPerNet + CAFormer-B36, trained on soft labels). They
|
|
87
|
+
carry over to the current Unet++ model, which scores higher with them on every measure below.
|
|
88
|
+
For another kind of model, re-tune the pipeline on its predictions for scenes it never trained
|
|
89
|
+
on; tuning needs instance annotations.
|
|
90
|
+
- Two contrails crossing at a small angle come out as one: at 10° always, at 20° with the
|
|
91
|
+
consensus settings. Touching, nearly parallel contrails also merge.
|
|
92
|
+
- The COCO mAP keeps at most 100 detections per sample (a warning says when a sample has more).
|
|
93
|
+
|
|
94
|
+
## With a pretrained DeepContrail model
|
|
95
|
+
|
|
96
|
+
A pretrained bundle of `deepcontrail-segmentation` may carry the instance step tuned for its
|
|
97
|
+
model: `instance_pipeline.yaml` and a `score_model.json` trained on that model's predictions.
|
|
98
|
+
`load_instance_step` reads them from the bundle's folder (the defaults when they are absent) and
|
|
99
|
+
refuses a score model made with the other test-time-augmentation setting:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from deepcontrail.instance import load_instance_step, to_coco_results
|
|
103
|
+
from deepcontrail.segmentation import load_pretrained, predict
|
|
104
|
+
|
|
105
|
+
model = load_pretrained("eurocontrol-asu/deepcontrail-segmentation")
|
|
106
|
+
step = load_instance_step(model.directory, tta=model.config.tta)
|
|
107
|
+
probabilities = predict(model, composite, tta=model.config.tta)
|
|
108
|
+
masks, scores = step(probabilities)
|
|
109
|
+
results = to_coco_results(masks, scores, image_id="scene_1") # JSON-ready, needs [map]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A score model records what it was trained on (`ScoreModel.trained_on`: semantic model, TTA,
|
|
113
|
+
scene size, scikit-learn and package versions); the pipeline refuses it on scenes of another size,
|
|
114
|
+
since its features are in pixels. `save_instance_step` writes the two files into a bundle folder.
|
|
115
|
+
|
|
116
|
+
## Annotations and results
|
|
117
|
+
|
|
118
|
+
The annotation tools need only `instance_masks`: each annotator's mask, the agreement map, the
|
|
119
|
+
consensus mask and the consensus contrails (drawn by more than half of the annotators) are derived
|
|
120
|
+
from it by `deepcontrail.instance.labels`, for any number of annotators, one included.
|
|
121
|
+
|
|
122
|
+
Instance annotations use the `instance_masks/<split>/<id>.npy` format of the
|
|
123
|
+
[DeepContrail dataset format](https://github.com/eurocontrol-asu/deepcontrail/tree/main/packages/core): an `(N, H, W, A)` int8 array (1 = pixel of contrail n
|
|
124
|
+
drawn by annotator a, 0 = not, -1 = annotator a drew no contrail n; `A = 1` for one annotator),
|
|
125
|
+
read with `deepcontrail.core.readers.load_instance_masks`. `instance_masks_from_label_image` and
|
|
126
|
+
`instance_masks_from_annotators` build it from a label image or from per-annotator masks.
|
|
127
|
+
|
|
128
|
+
`to_coco_results(masks, scores, image_id)` (needs `[map]`) saves detections as COCO results with
|
|
129
|
+
RLE masks, so overlapping contrails stay separate:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
import json
|
|
133
|
+
from deepcontrail.instance import to_coco_results
|
|
134
|
+
|
|
135
|
+
json.dump(to_coco_results(masks, scores, image_id="scene_1"), open("instances.json", "w"))
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Learned scores
|
|
139
|
+
|
|
140
|
+
A `ScoreModel` predicts, from 20 features of each detected contrail (size, shape, probabilities,
|
|
141
|
+
surroundings), how well it matches the annotated contrails, and that prediction replaces its
|
|
142
|
+
score; the masks do not change. The features depend on the semantic model, so a score model is
|
|
143
|
+
valid only for the semantic model and pipeline settings it was trained with, and refuses other
|
|
144
|
+
pipeline settings. Training (`ScoreModel.fit`, with `training_data`) needs `[scores]`; a saved
|
|
145
|
+
model is plain JSON and predicts with numpy only:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from deepcontrail.instance import (
|
|
149
|
+
InstancePipelineConfig,
|
|
150
|
+
ScoreModel,
|
|
151
|
+
detect_instances_from_probabilities,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
config = InstancePipelineConfig() # the shipped defaults, = configs/instance_pipeline.yaml
|
|
155
|
+
model = ScoreModel.from_json("score_model.json") # from a bundle, or train_score_model.py
|
|
156
|
+
masks, scores = detect_instances_from_probabilities(probs, config, score_model=model)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Evaluation
|
|
160
|
+
|
|
161
|
+
`deepcontrail.instance.metrics.evaluate_annotator_samples` (needs `[map]`) scores detections
|
|
162
|
+
against each annotator's contrails as a separate ground truth: one sample per (scene, annotator)
|
|
163
|
+
pair. The headline is the COCO segmentation mAP averaged over IoU 0.25–0.75, pooled over all
|
|
164
|
+
samples; precision, recall and F1 are at IoU 0.5. Samples where the annotator drew no contrail are
|
|
165
|
+
skipped by default; `include_empty=True` keeps them, so every detection on them counts as false,
|
|
166
|
+
which is closer to real use.
|
|
167
|
+
|
|
168
|
+
Results on the OpenContrails 2025 test split (1,879 scenes, 2,700 annotator samples with at least
|
|
169
|
+
one contrail), from the probabilities of DeepContrail's reference semantic model (Unet++ +
|
|
170
|
+
CAFormer-B36, D4 TTA); the learned scores were trained on its validation predictions. Every
|
|
171
|
+
setting was chosen on validation scenes; the test split was only scored. The consensus columns
|
|
172
|
+
score each scene once against its consensus contrails.
|
|
173
|
+
|
|
174
|
+
| Settings | mAP | mAP, with empty | Precision (with empty) | Recall | F1 (with empty) | Consensus mAP | Consensus mAP, all scenes |
|
|
175
|
+
|---|---|---|---|---|---|---|---|
|
|
176
|
+
| `configs/instance_pipeline.yaml` (defaults) | 0.273 | 0.268 | 0.532 (0.504) | 0.387 | 0.448 (0.438) | 0.424 | 0.417 |
|
|
177
|
+
| `configs/instance_pipeline_consensus.yaml` (no growth) | 0.252 | 0.248 | 0.510 (0.485) | 0.350 | 0.415 (0.407) | 0.446 | 0.439 |
|
|
178
|
+
| defaults + learned scores | 0.280 | 0.273 | 0.532 (0.504) | 0.387 | 0.448 (0.438) | 0.435 | 0.424 |
|
|
179
|
+
| consensus settings + learned scores | 0.258 | 0.253 | 0.510 (0.485) | 0.350 | 0.415 (0.407) | 0.459 | 0.450 |
|
|
180
|
+
|
|
181
|
+
On each annotator's own mask instead of a prediction, the instance step alone reaches mAP 0.921
|
|
182
|
+
(`configs/mannstein_annotator_masks.yaml`). Most errors therefore come from the semantic step and
|
|
183
|
+
from contrail shape: the AP is 0.50 at IoU 0.25 but 0.02 at IoU 0.75. Annotators also disagree
|
|
184
|
+
strongly on individual contrails: one annotator scored against the others reaches F1 ≈ 0.35 on
|
|
185
|
+
validation scenes.
|
|
186
|
+
|
|
187
|
+
## Scripts (repository only)
|
|
188
|
+
|
|
189
|
+
Command-line tools in [`scripts/`](https://github.com/eurocontrol-asu/deepcontrail/tree/main/packages/instance/scripts), run from the repository root. They read only
|
|
190
|
+
`instance_masks/<split>/` from a dataset, plus one `<id>.npy` probability map per scene for a
|
|
191
|
+
semantic model's predictions.
|
|
192
|
+
|
|
193
|
+
- **`evaluate_instances.py`**: the evaluation above, on predictions (`--pred-dir`), on the
|
|
194
|
+
consensus mask (`--use-gt-semantic`) or on each annotator's mask (`--use-annotator-semantic`);
|
|
195
|
+
`--score-model`, `--include-empty`, `--consensus-gt`.
|
|
196
|
+
- **`train_score_model.py`**: trains a `ScoreModel` (needs `[scores]`).
|
|
197
|
+
- **`optimize_pipeline.py`** and **`optimize_mannstein.py`**: Optuna searches of the pipeline and
|
|
198
|
+
Mannstein settings (optuna is in the repository's `experiments` group); they never read the
|
|
199
|
+
test split.
|
|
200
|
+
|
|
201
|
+
Tuning for your own semantic model: predict the scenes it never trained on (for example its
|
|
202
|
+
validation split), search the pipeline settings on them (`optimize_pipeline.py`), train a score
|
|
203
|
+
model on the same predictions (`train_score_model.py`), then score the test split once
|
|
204
|
+
(`evaluate_instances.py`).
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
uv run python packages/instance/scripts/train_score_model.py --root <dataset_root> \
|
|
208
|
+
--pred-dir <validation probability maps> \
|
|
209
|
+
--pipeline-config packages/instance/configs/instance_pipeline.yaml --output-dir <dir>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## References and acknowledgements
|
|
213
|
+
|
|
214
|
+
The line filter and the size, length and straightness tests follow Mannstein, Meyer & Wendling
|
|
215
|
+
(1999), "Operational detection of contrails from NOAA-AVHRR data", *International Journal of
|
|
216
|
+
Remote Sensing* 20(8), 1641–1660, [doi:10.1080/014311699212650](https://doi.org/10.1080/014311699212650).
|
|
217
|
+
|
|
218
|
+
The direction-following region growing and the related shape tests build on ideas from a
|
|
219
|
+
student project (PIE) at ISAE-SUPAERO, carried out with EUROCONTROL.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
deepcontrail/instance/__init__.py,sha256=RfHWIc4w7MTQbkRgxtNm_Pvx91CoM8kZJF6VORZv0kY,1553
|
|
2
|
+
deepcontrail/instance/_evaluation.py,sha256=Tipiy5ovep_Phn7QOE0UUTttg819QF_q94OGx2uFnP8,13934
|
|
3
|
+
deepcontrail/instance/_growing.py,sha256=eX9a2ups7yZf0knHSn7he-SgtslLVXkKrSjZZyHe8iA,9062
|
|
4
|
+
deepcontrail/instance/_lines.py,sha256=u2C8VZ4dXOAJ0zOkqOwm28tY-ynUulUsCIv3WywUEw0,5423
|
|
5
|
+
deepcontrail/instance/_overlap.py,sha256=J5vNicmbQNZoAIM6LT5DRy7X3xHVVjjSxydTUARySas,2598
|
|
6
|
+
deepcontrail/instance/_shape.py,sha256=-wZq4BJ-u5s4zKio-rKpq0_OJgZ4eSq3itreBQBbNLs,6686
|
|
7
|
+
deepcontrail/instance/_tuning.py,sha256=h9VqwjwLTTZJ5Lfwtf24ZWRA1TSTJw5HCEhdYNgTlME,3803
|
|
8
|
+
deepcontrail/instance/bundle.py,sha256=PT3dVRUk4CHETilb5siJ_JXbGc_qXOg-OiDMccAgrk0,4392
|
|
9
|
+
deepcontrail/instance/config.py,sha256=bksNx0unjNLUjl3Y9b1ufoQfzMW1VKjuT4tvJQ8vw1o,9710
|
|
10
|
+
deepcontrail/instance/io.py,sha256=-uZMtgF2WXlhqUn2EtQBIi6y9e-6sQ5El_UpOH9i1l4,4853
|
|
11
|
+
deepcontrail/instance/labels.py,sha256=9h_P7nqi7eNUJgqlZodwZXG4yWR3ia0trLyyA2TymCU,8366
|
|
12
|
+
deepcontrail/instance/mannstein.py,sha256=zIPW6PMOQBjg-RXkbgEjRxA3S21wlkbcWSCKqrZ9Br0,3996
|
|
13
|
+
deepcontrail/instance/metrics.py,sha256=bCi4L6Yw5VuGejZSe_6cBzuxNPliuqHRa2jSroGpoIA,9096
|
|
14
|
+
deepcontrail/instance/pipeline.py,sha256=LRQLpJoQEA-EF3C-e4buyB5NzFLJ5SbAnd6In4QsYd4,3529
|
|
15
|
+
deepcontrail/instance/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
deepcontrail/instance/refine.py,sha256=ZIzm5o_HSfBw4vKmEP103m02IZem9dr_UP9od00MOXg,4080
|
|
17
|
+
deepcontrail/instance/scoring.py,sha256=k1m9dIAa2zFce6Fq0kbfUhE1X1rmFWpV-oL7Fx8_ufI,15541
|
|
18
|
+
deepcontrail_instance-0.1.0.dist-info/licenses/LICENSE,sha256=Nmh3Jhi6ZRNFGD34EKZM-xbDySWW9ur1kKYvTaZW2cA,13705
|
|
19
|
+
deepcontrail_instance-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
20
|
+
deepcontrail_instance-0.1.0.dist-info/METADATA,sha256=MgssxtYuc3OgtcEi_TH7edGUaDIKYIz3xCu1wlrQ15Y,12193
|
|
21
|
+
deepcontrail_instance-0.1.0.dist-info/RECORD,,
|