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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any