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,114 @@
|
|
|
1
|
+
"""Top-level Mannstein-style instance-segmentation pipeline.
|
|
2
|
+
|
|
3
|
+
Public entry point that threads the private line-filter, segmentation, scoring and
|
|
4
|
+
deduplication modules together into ``detect_instances`` (binary mask in; masks and scores
|
|
5
|
+
out). Probability maps go through ``detect_instances_from_probabilities``.
|
|
6
|
+
|
|
7
|
+
The detection criteria follow Mannstein, Meyer & Wendling (1999), "Operational detection of
|
|
8
|
+
contrails from NOAA-AVHRR data", Int. J. Remote Sensing 20(8), 1641-1660,
|
|
9
|
+
doi:10.1080/014311699212650.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from deepcontrail.instance._growing import grow_regions
|
|
17
|
+
from deepcontrail.instance._lines import detect_line_pixels
|
|
18
|
+
from deepcontrail.instance._overlap import remove_duplicates
|
|
19
|
+
from deepcontrail.instance._shape import candidate_score
|
|
20
|
+
from deepcontrail.instance.config import MannsteinConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run_pipeline(
|
|
24
|
+
semantic_mask: np.ndarray,
|
|
25
|
+
config: MannsteinConfig,
|
|
26
|
+
) -> tuple[list[tuple[np.ndarray, np.ndarray]], list[float]]:
|
|
27
|
+
"""Optionally line-filter, then segment, score and deduplicate into ``(contrails, scores)``."""
|
|
28
|
+
if config.line_filter:
|
|
29
|
+
mask = detect_line_pixels(
|
|
30
|
+
semantic_mask.astype(np.float32),
|
|
31
|
+
config.num_kernels,
|
|
32
|
+
config.kernel_size,
|
|
33
|
+
config.sigma1,
|
|
34
|
+
config.sigma_delta,
|
|
35
|
+
config.response_threshold,
|
|
36
|
+
)
|
|
37
|
+
else:
|
|
38
|
+
mask = semantic_mask > 0
|
|
39
|
+
|
|
40
|
+
candidates = grow_regions(
|
|
41
|
+
mask,
|
|
42
|
+
config.min_pixels,
|
|
43
|
+
config.min_length,
|
|
44
|
+
config.min_r_score,
|
|
45
|
+
config.lookback,
|
|
46
|
+
config.alignment_threshold,
|
|
47
|
+
config.fit_avg_size if config.filter_avg_size is None else config.filter_avg_size,
|
|
48
|
+
)
|
|
49
|
+
survivors = remove_duplicates(candidates, config.iou_threshold)
|
|
50
|
+
return survivors, [
|
|
51
|
+
candidate_score(rows, cols, config.score_saturation, config.fit_avg_size)
|
|
52
|
+
for rows, cols in survivors
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _masks_from_contrails(
|
|
57
|
+
contrails: list[tuple[np.ndarray, np.ndarray]],
|
|
58
|
+
h: int,
|
|
59
|
+
w: int,
|
|
60
|
+
) -> list[np.ndarray]:
|
|
61
|
+
masks: list[np.ndarray] = []
|
|
62
|
+
for rows, cols in contrails:
|
|
63
|
+
mask = np.zeros((h, w), dtype=bool)
|
|
64
|
+
mask[rows, cols] = True
|
|
65
|
+
masks.append(mask)
|
|
66
|
+
return masks
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _check_binary_mask(mask: np.ndarray) -> np.ndarray:
|
|
70
|
+
"""Return ``mask`` as an array after checking it is a 2-D, finite, binary mask."""
|
|
71
|
+
mask = np.asarray(mask)
|
|
72
|
+
if mask.ndim != 2:
|
|
73
|
+
raise ValueError(f"mask must be a 2-D (H, W) array, got shape {mask.shape}")
|
|
74
|
+
if mask.dtype != bool and not np.all(np.isfinite(mask)):
|
|
75
|
+
raise ValueError("mask must be finite (no NaN or inf)")
|
|
76
|
+
if mask.dtype != bool and not np.isin(mask, (0, 1)).all():
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"mask must be binary (0/1 or bool); for a probability map use "
|
|
79
|
+
"detect_instances_from_probabilities"
|
|
80
|
+
)
|
|
81
|
+
return mask
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def detect_instances(
|
|
85
|
+
semantic_mask: np.ndarray, config: MannsteinConfig | None = None
|
|
86
|
+
) -> tuple[list[np.ndarray], list[float]]:
|
|
87
|
+
"""Split a binary contrail mask into separate contrails with plain Mannstein.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
semantic_mask
|
|
92
|
+
Binary 2D array ``(H, W)``: bool, or numbers in ``{0, 1}``.
|
|
93
|
+
config
|
|
94
|
+
Algorithm configuration. Defaults to ``MannsteinConfig()``.
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
tuple[list[np.ndarray], list[float]]
|
|
99
|
+
Boolean instance masks ``(H, W)`` and their quality scores in ``[0, 1]``, aligned
|
|
100
|
+
one-to-one; both empty when no contrail is found. Contrails may share pixels where
|
|
101
|
+
they cross.
|
|
102
|
+
|
|
103
|
+
Raises
|
|
104
|
+
------
|
|
105
|
+
ValueError
|
|
106
|
+
If ``semantic_mask`` is not 2-D, not finite or not binary (a probability map belongs
|
|
107
|
+
to ``detect_instances_from_probabilities``).
|
|
108
|
+
"""
|
|
109
|
+
mask = _check_binary_mask(semantic_mask)
|
|
110
|
+
if config is None:
|
|
111
|
+
config = MannsteinConfig()
|
|
112
|
+
h, w = mask.shape
|
|
113
|
+
contrails, scores = _run_pipeline(mask, config)
|
|
114
|
+
return _masks_from_contrails(contrails, h, w), scores
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Instance metrics across independent annotators: COCO mAP, precision, recall and F1.
|
|
2
|
+
|
|
3
|
+
The mAP needs the ``[map]`` extra (pycocotools); torch is never needed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import warnings
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
DEFAULT_IOU_THRESHOLDS = (0.25, 0.35, 0.45, 0.55, 0.65, 0.75)
|
|
14
|
+
COCO_MAX_DETECTIONS = 100
|
|
15
|
+
"""Detections per sample kept by the COCO mAP (pycocotools default; extra ones are dropped)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def match_instances(
|
|
19
|
+
pred_masks: list[np.ndarray],
|
|
20
|
+
gt_masks: list[np.ndarray],
|
|
21
|
+
iou_threshold: float = 0.5,
|
|
22
|
+
) -> tuple[int, int, int]:
|
|
23
|
+
"""Greedy one-to-one IoU matching of predicted and ground-truth masks.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
pred_masks
|
|
28
|
+
Predicted boolean instance masks, each ``(H, W)``.
|
|
29
|
+
gt_masks
|
|
30
|
+
Ground-truth boolean instance masks, each ``(H, W)``.
|
|
31
|
+
iou_threshold
|
|
32
|
+
Minimum IoU for a pred/GT pair to count as a match.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
tuple[int, int, int]
|
|
37
|
+
``(tp, fp, fn)``. Every pred and GT is matched at most once, always
|
|
38
|
+
taking the highest-IoU available pair first. Unmatched preds are false
|
|
39
|
+
positives and unmatched GT are false negatives. Empty inputs are valid.
|
|
40
|
+
"""
|
|
41
|
+
n_pred = len(pred_masks)
|
|
42
|
+
n_gt = len(gt_masks)
|
|
43
|
+
if n_pred == 0 and n_gt == 0:
|
|
44
|
+
return 0, 0, 0
|
|
45
|
+
|
|
46
|
+
matched_pred = [False] * n_pred
|
|
47
|
+
matched_gt = [False] * n_gt
|
|
48
|
+
pairs: list[tuple[float, int, int]] = []
|
|
49
|
+
for i, pred in enumerate(pred_masks):
|
|
50
|
+
for j, gt in enumerate(gt_masks):
|
|
51
|
+
intersection = np.logical_and(pred, gt).sum()
|
|
52
|
+
union = np.logical_or(pred, gt).sum()
|
|
53
|
+
iou = float(intersection / union) if union else 0.0
|
|
54
|
+
if iou >= iou_threshold:
|
|
55
|
+
pairs.append((iou, i, j))
|
|
56
|
+
|
|
57
|
+
pairs.sort(key=lambda pair: pair[0], reverse=True)
|
|
58
|
+
tp = 0
|
|
59
|
+
for _iou, i, j in pairs:
|
|
60
|
+
if not matched_pred[i] and not matched_gt[j]:
|
|
61
|
+
matched_pred[i] = True
|
|
62
|
+
matched_gt[j] = True
|
|
63
|
+
tp += 1
|
|
64
|
+
|
|
65
|
+
return tp, n_pred - tp, n_gt - tp
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def evaluate_annotator_samples(
|
|
69
|
+
pred_masks: list[list[np.ndarray]],
|
|
70
|
+
gt_masks: list[list[np.ndarray]],
|
|
71
|
+
pred_scores: list[list[float]] | None = None,
|
|
72
|
+
iou_thresholds: tuple[float, ...] | list[float] = DEFAULT_IOU_THRESHOLDS,
|
|
73
|
+
match_iou: float = 0.5,
|
|
74
|
+
per_threshold: bool = True,
|
|
75
|
+
include_empty: bool = False,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
"""Score instance predictions per annotator sample (pooled COCO segmentation mAP).
|
|
78
|
+
|
|
79
|
+
One sample is one annotator on one scene. Samples whose ground truth is empty are
|
|
80
|
+
skipped (the official protocol) unless ``include_empty``. All remaining samples are
|
|
81
|
+
pooled into a single COCO segmentation mAP computation (one image per sample, single
|
|
82
|
+
class), averaged over ``iou_thresholds``. Precision, recall and F1 from one-to-one
|
|
83
|
+
matching at ``match_iou``, pooled over the same samples, are returned as a reference.
|
|
84
|
+
|
|
85
|
+
With ``include_empty``, every sample is kept and every detection on an empty sample is a
|
|
86
|
+
false detection, as in real use where most scenes have no contrail.
|
|
87
|
+
|
|
88
|
+
The computation is the standard COCO segmentation AP of pycocotools (101 recall points, at
|
|
89
|
+
most 100 detections per sample, area "all", with a warning when a sample has more); torch is
|
|
90
|
+
not needed. Every sample is registered
|
|
91
|
+
with COCO, so ``include_empty`` counts the detections on empty samples (torchmetrics'
|
|
92
|
+
``MeanAveragePrecision`` drops them).
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
pred_masks
|
|
97
|
+
``pred_masks[i]`` is the list of predicted boolean ``(H, W)`` masks for sample ``i``.
|
|
98
|
+
gt_masks
|
|
99
|
+
``gt_masks[i]`` is the list of GT boolean masks of sample ``i`` (one annotator).
|
|
100
|
+
pred_scores
|
|
101
|
+
Confidence per predicted mask, aligned with ``pred_masks``; ``None`` = 1.0 for all.
|
|
102
|
+
iou_thresholds
|
|
103
|
+
IoU thresholds averaged by the mAP. Defaults to 0.25, 0.35, ..., 0.75.
|
|
104
|
+
match_iou
|
|
105
|
+
IoU threshold of the reference one-to-one matching.
|
|
106
|
+
per_threshold
|
|
107
|
+
Also return the AP at each threshold.
|
|
108
|
+
include_empty
|
|
109
|
+
Keep samples with an empty ground truth and count their detections as false.
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
dict[str, Any]
|
|
114
|
+
``map``, ``ap_per_threshold`` (when requested), ``precision``, ``recall``, ``f1``,
|
|
115
|
+
``tp``, ``fp``, ``fn``, ``n_samples`` and ``n_skipped_empty``.
|
|
116
|
+
|
|
117
|
+
Raises
|
|
118
|
+
------
|
|
119
|
+
ValueError
|
|
120
|
+
If ``pred_masks``, ``gt_masks`` (and ``pred_scores``) have different lengths.
|
|
121
|
+
"""
|
|
122
|
+
if len(pred_masks) != len(gt_masks) or (
|
|
123
|
+
pred_scores is not None and len(pred_scores) != len(pred_masks)
|
|
124
|
+
):
|
|
125
|
+
raise ValueError("pred_masks, gt_masks and pred_scores must have one entry per samples")
|
|
126
|
+
keep = [i for i, gt in enumerate(gt_masks) if gt or include_empty]
|
|
127
|
+
preds = [pred_masks[i] for i in keep]
|
|
128
|
+
gts = [gt_masks[i] for i in keep]
|
|
129
|
+
scores = None if pred_scores is None else [pred_scores[i] for i in keep]
|
|
130
|
+
|
|
131
|
+
tp = fp = fn = 0
|
|
132
|
+
for p, g in zip(preds, gts, strict=True):
|
|
133
|
+
a, b, c = match_instances(p, g, match_iou)
|
|
134
|
+
tp, fp, fn = tp + a, fp + b, fn + c
|
|
135
|
+
precision = tp / (tp + fp) if tp + fp else 0.0
|
|
136
|
+
recall = tp / (tp + fn) if tp + fn else 0.0
|
|
137
|
+
crowded = sum(len(p) > COCO_MAX_DETECTIONS for p in preds)
|
|
138
|
+
if crowded:
|
|
139
|
+
warnings.warn(
|
|
140
|
+
f"{crowded} sample(s) have more than {COCO_MAX_DETECTIONS} detections; the COCO mAP "
|
|
141
|
+
f"keeps only the {COCO_MAX_DETECTIONS} detections with the highest scores per sample",
|
|
142
|
+
UserWarning,
|
|
143
|
+
stacklevel=2,
|
|
144
|
+
)
|
|
145
|
+
pooled, ap = _coco_ap_all_samples(preds, gts, scores, list(iou_thresholds))
|
|
146
|
+
result: dict[str, Any] = {
|
|
147
|
+
"map": pooled,
|
|
148
|
+
"precision": precision,
|
|
149
|
+
"recall": recall,
|
|
150
|
+
"f1": 2 * precision * recall / (precision + recall) if precision + recall else 0.0,
|
|
151
|
+
"tp": tp,
|
|
152
|
+
"fp": fp,
|
|
153
|
+
"fn": fn,
|
|
154
|
+
"n_samples": len(keep),
|
|
155
|
+
"n_skipped_empty": len(gt_masks) - len(keep),
|
|
156
|
+
}
|
|
157
|
+
if per_threshold:
|
|
158
|
+
result["ap_per_threshold"] = dict(zip(iou_thresholds, ap.tolist(), strict=True))
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _coco_ap_all_samples(
|
|
163
|
+
preds: list[list[np.ndarray]],
|
|
164
|
+
gts: list[list[np.ndarray]],
|
|
165
|
+
scores: list[list[float]] | None,
|
|
166
|
+
thresholds: list[float],
|
|
167
|
+
) -> tuple[float, np.ndarray]:
|
|
168
|
+
"""Pooled mAP and AP per threshold, every sample registered in COCO (empty GT included).
|
|
169
|
+
|
|
170
|
+
Standard COCO segmentation AP (pycocotools defaults: 101 recall points, 100 detections,
|
|
171
|
+
area "all"), with ``thresholds`` as IoU thresholds. Zeros when there is nothing to score.
|
|
172
|
+
"""
|
|
173
|
+
import contextlib
|
|
174
|
+
import io
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
import pycocotools.mask as mask_utils
|
|
178
|
+
from pycocotools.coco import COCO
|
|
179
|
+
from pycocotools.cocoeval import COCOeval
|
|
180
|
+
except ImportError as err:
|
|
181
|
+
raise ImportError(
|
|
182
|
+
"the COCO mAP needs pycocotools: pip install 'deepcontrail-instance[map]'"
|
|
183
|
+
) from err
|
|
184
|
+
|
|
185
|
+
images: list[dict[str, Any]] = []
|
|
186
|
+
gt_anns: list[dict[str, Any]] = []
|
|
187
|
+
dt_anns: list[dict[str, Any]] = []
|
|
188
|
+
for image_id, (p, g) in enumerate(zip(preds, gts, strict=True)):
|
|
189
|
+
h, w = (g or p)[0].shape if (g or p) else (1, 1)
|
|
190
|
+
images.append({"id": image_id, "height": h, "width": w})
|
|
191
|
+
for m in g:
|
|
192
|
+
rle = mask_utils.encode(np.asfortranarray(m.astype(np.uint8)))
|
|
193
|
+
gt_anns.append(
|
|
194
|
+
{"id": len(gt_anns) + 1, "image_id": image_id, "category_id": 0,
|
|
195
|
+
"segmentation": rle, "area": float(mask_utils.area(rle)), "iscrowd": 0}
|
|
196
|
+
) # fmt: skip
|
|
197
|
+
for k, m in enumerate(p):
|
|
198
|
+
rle = mask_utils.encode(np.asfortranarray(m.astype(np.uint8)))
|
|
199
|
+
score = 1.0 if scores is None else float(scores[image_id][k])
|
|
200
|
+
dt_anns.append(
|
|
201
|
+
{"id": len(dt_anns) + 1, "image_id": image_id, "category_id": 0,
|
|
202
|
+
"segmentation": rle, "area": float(mask_utils.area(rle)), "score": score}
|
|
203
|
+
) # fmt: skip
|
|
204
|
+
if not gt_anns:
|
|
205
|
+
return 0.0, np.zeros(len(thresholds))
|
|
206
|
+
categories = [{"id": 0, "name": "0"}]
|
|
207
|
+
coco_gt, coco_dt = COCO(), COCO()
|
|
208
|
+
# In-memory datasets (the pycocotools stubs mark ``dataset`` read-only).
|
|
209
|
+
cast("Any", coco_gt).dataset = {
|
|
210
|
+
"images": images,
|
|
211
|
+
"annotations": gt_anns,
|
|
212
|
+
"categories": categories,
|
|
213
|
+
}
|
|
214
|
+
cast("Any", coco_dt).dataset = {
|
|
215
|
+
"images": images,
|
|
216
|
+
"annotations": dt_anns,
|
|
217
|
+
"categories": categories,
|
|
218
|
+
}
|
|
219
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
220
|
+
coco_gt.createIndex()
|
|
221
|
+
coco_dt.createIndex()
|
|
222
|
+
coco_eval = COCOeval(coco_gt, coco_dt, iouType="segm")
|
|
223
|
+
coco_eval.params.iouThrs = np.array(thresholds, dtype=np.float64)
|
|
224
|
+
coco_eval.evaluate()
|
|
225
|
+
coco_eval.accumulate()
|
|
226
|
+
precision = coco_eval.eval["precision"][:, :, 0, 0, -1]
|
|
227
|
+
|
|
228
|
+
def summary(values: np.ndarray) -> float:
|
|
229
|
+
valid = values[values > -1]
|
|
230
|
+
return max(float(np.mean(valid)), 0.0) if valid.size else 0.0
|
|
231
|
+
|
|
232
|
+
return summary(precision), np.array([summary(row) for row in precision])
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Instance detection from a semantic probability map.
|
|
2
|
+
|
|
3
|
+
Threshold, split with Mannstein, grow into moderate-probability pixels, then score
|
|
4
|
+
(optionally with a learned :class:`deepcontrail.instance.scoring.ScoreModel`).
|
|
5
|
+
See :class:`deepcontrail.instance.config.InstancePipelineConfig`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from deepcontrail.instance.config import InstancePipelineConfig
|
|
15
|
+
from deepcontrail.instance.mannstein import detect_instances
|
|
16
|
+
from deepcontrail.instance.refine import grow_instances, probability_weighted_scores
|
|
17
|
+
from deepcontrail.instance.scoring import scene_features
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from deepcontrail.instance.scoring import ScoreModel
|
|
21
|
+
|
|
22
|
+
_TOLERANCE = 1e-3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_instances_from_probabilities(
|
|
26
|
+
probs: np.ndarray,
|
|
27
|
+
config: InstancePipelineConfig | None = None,
|
|
28
|
+
score_model: ScoreModel | None = None,
|
|
29
|
+
) -> tuple[list[np.ndarray], list[float]]:
|
|
30
|
+
"""Detect contrail instances in a semantic probability map.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
probs
|
|
35
|
+
Probability map ``(H, W)`` with values in ``[0, 1]``, e.g. the sigmoid output of a
|
|
36
|
+
semantic segmentation model.
|
|
37
|
+
config
|
|
38
|
+
Pipeline settings. Defaults to ``InstancePipelineConfig()``.
|
|
39
|
+
score_model
|
|
40
|
+
Optional learned scores: replaces each contrail's score by the model's prediction.
|
|
41
|
+
It must have been trained on detections made with the same ``config``.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
tuple[list[np.ndarray], list[float]]
|
|
46
|
+
Boolean instance masks ``(H, W)`` and their scores in ``[0, 1]``, aligned one-to-one.
|
|
47
|
+
Instances may share pixels where contrails cross.
|
|
48
|
+
|
|
49
|
+
Raises
|
|
50
|
+
------
|
|
51
|
+
ValueError
|
|
52
|
+
If ``probs`` is not 2-D, not finite or outside ``[0, 1]``, or if ``score_model`` was
|
|
53
|
+
trained with another pipeline config or on scenes of another size.
|
|
54
|
+
"""
|
|
55
|
+
if config is None:
|
|
56
|
+
config = InstancePipelineConfig()
|
|
57
|
+
if score_model is not None and score_model.pipeline_config != config:
|
|
58
|
+
raise ValueError("the score model was trained with another pipeline config")
|
|
59
|
+
probs = np.asarray(probs, dtype=np.float32)
|
|
60
|
+
if probs.ndim != 2:
|
|
61
|
+
raise ValueError(f"probabilities must be a 2-D (H, W) array, got shape {probs.shape}")
|
|
62
|
+
if not np.all(np.isfinite(probs)):
|
|
63
|
+
raise ValueError("probabilities must be finite (no NaN or inf)")
|
|
64
|
+
if probs.size and (probs.min() < -_TOLERANCE or probs.max() > 1.0 + _TOLERANCE):
|
|
65
|
+
raise ValueError("probabilities must lie in [0, 1]")
|
|
66
|
+
scene_shape = score_model.trained_on.get("scene_shape") if score_model is not None else None
|
|
67
|
+
if scene_shape is not None and tuple(scene_shape) != probs.shape:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"the score model was trained on {tuple(scene_shape)} scenes, got {probs.shape}: "
|
|
70
|
+
"its features are in pixels, so retrain it for this scene size"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
binary = (probs > config.semantic_threshold).astype(np.uint8)
|
|
74
|
+
if not binary.any():
|
|
75
|
+
return [], []
|
|
76
|
+
|
|
77
|
+
masks, scores = detect_instances(binary, config.mannstein)
|
|
78
|
+
masks = grow_instances(
|
|
79
|
+
masks,
|
|
80
|
+
probs,
|
|
81
|
+
scores,
|
|
82
|
+
threshold=config.grow_threshold,
|
|
83
|
+
steps=config.grow_steps,
|
|
84
|
+
connectivity=config.grow_connectivity,
|
|
85
|
+
)
|
|
86
|
+
if config.probability_scores:
|
|
87
|
+
scores = probability_weighted_scores(masks, probs, scores)
|
|
88
|
+
if score_model is not None:
|
|
89
|
+
scores = [float(v) for v in score_model.predict(scene_features(masks, probs, scores))]
|
|
90
|
+
return masks, scores
|
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Refinement of detected instances using the semantic probability map.
|
|
2
|
+
|
|
3
|
+
Annotators draw contrails wider than a semantic mask thresholded at 0.5: the model is
|
|
4
|
+
trained on agreement-weighted soft labels, so contrail edges where only some annotators
|
|
5
|
+
agree fall below 0.5. Growing each instance into neighbouring pixels of moderate
|
|
6
|
+
probability brings its shape closer to an annotated contrail.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from scipy.ndimage import binary_dilation
|
|
13
|
+
|
|
14
|
+
_STRUCTURES = {
|
|
15
|
+
4: np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=bool),
|
|
16
|
+
8: np.ones((3, 3), dtype=bool),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def grow_instances(
|
|
21
|
+
masks: list[np.ndarray],
|
|
22
|
+
probs: np.ndarray,
|
|
23
|
+
scores: list[float],
|
|
24
|
+
threshold: float,
|
|
25
|
+
steps: int,
|
|
26
|
+
connectivity: int = 4,
|
|
27
|
+
) -> list[np.ndarray]:
|
|
28
|
+
"""Grow instances into neighbouring pixels whose probability exceeds ``threshold``.
|
|
29
|
+
|
|
30
|
+
Each step adds, to every instance, its neighbours (4- or 8-connected) with
|
|
31
|
+
``probs > threshold`` that belong to no instance yet. A pixel reached by several
|
|
32
|
+
instances in the same step goes to the highest-scoring one (the first one on ties),
|
|
33
|
+
so every newly grown pixel belongs to exactly one instance. Pixels an instance
|
|
34
|
+
already had are always kept, and pixels of other instances are never taken.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
masks
|
|
39
|
+
Boolean instance masks, each ``(H, W)``.
|
|
40
|
+
probs
|
|
41
|
+
Semantic probability map ``(H, W)`` in ``[0, 1]``.
|
|
42
|
+
scores
|
|
43
|
+
One score per instance, used to settle contested pixels.
|
|
44
|
+
threshold
|
|
45
|
+
Growth only enters pixels with ``probs > threshold``.
|
|
46
|
+
steps
|
|
47
|
+
Number of growth steps (0 returns copies of the input).
|
|
48
|
+
connectivity
|
|
49
|
+
4 or 8.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
list[np.ndarray]
|
|
54
|
+
Grown boolean masks, aligned with ``masks``.
|
|
55
|
+
|
|
56
|
+
Raises
|
|
57
|
+
------
|
|
58
|
+
ValueError
|
|
59
|
+
If ``connectivity`` is not 4 or 8, ``steps`` is negative, or ``scores`` does
|
|
60
|
+
not have one entry per mask.
|
|
61
|
+
"""
|
|
62
|
+
if connectivity not in _STRUCTURES:
|
|
63
|
+
raise ValueError(f"connectivity must be 4 or 8, got {connectivity}")
|
|
64
|
+
if steps < 0:
|
|
65
|
+
raise ValueError(f"steps must be >= 0, got {steps}")
|
|
66
|
+
if len(scores) != len(masks):
|
|
67
|
+
raise ValueError(f"need one of scores per mask, got {len(scores)} for {len(masks)}")
|
|
68
|
+
grown = [m.astype(bool).copy() for m in masks]
|
|
69
|
+
if not grown or steps == 0:
|
|
70
|
+
return grown
|
|
71
|
+
|
|
72
|
+
structure = _STRUCTURES[connectivity]
|
|
73
|
+
allowed = probs > threshold
|
|
74
|
+
owned = np.any(np.stack(grown), axis=0)
|
|
75
|
+
order = sorted(range(len(grown)), key=lambda i: -scores[i])
|
|
76
|
+
for _ in range(steps):
|
|
77
|
+
claimed = np.zeros_like(owned)
|
|
78
|
+
additions = []
|
|
79
|
+
for i in order:
|
|
80
|
+
dilated = np.asarray(binary_dilation(grown[i], structure), dtype=bool)
|
|
81
|
+
new = dilated & allowed & ~owned & ~claimed
|
|
82
|
+
claimed |= new
|
|
83
|
+
additions.append((i, new))
|
|
84
|
+
if not claimed.any():
|
|
85
|
+
break
|
|
86
|
+
for i, new in additions:
|
|
87
|
+
grown[i] |= new
|
|
88
|
+
owned |= claimed
|
|
89
|
+
return grown
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def probability_weighted_scores(
|
|
93
|
+
masks: list[np.ndarray], probs: np.ndarray, scores: list[float]
|
|
94
|
+
) -> list[float]:
|
|
95
|
+
"""Multiply each instance score by the mean probability over the instance.
|
|
96
|
+
|
|
97
|
+
The semantic probability reflects how many annotators would mark a pixel, so it
|
|
98
|
+
ranks instances by expected agreement. Empty masks score 0.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
masks
|
|
103
|
+
Boolean instance masks, each ``(H, W)``.
|
|
104
|
+
probs
|
|
105
|
+
Semantic probability map ``(H, W)`` in ``[0, 1]``.
|
|
106
|
+
scores
|
|
107
|
+
Base score per instance (e.g. the Mannstein quality score).
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
list[float]
|
|
112
|
+
Weighted scores, aligned with ``masks``.
|
|
113
|
+
"""
|
|
114
|
+
weighted = []
|
|
115
|
+
for mask, score in zip(masks, scores, strict=True):
|
|
116
|
+
# A 0/1 int mask indexes ``probs`` by position rather than selecting its True pixels.
|
|
117
|
+
bool_mask = np.asarray(mask, dtype=bool)
|
|
118
|
+
weighted.append(float(score * probs[bool_mask].mean()) if bool_mask.any() else 0.0)
|
|
119
|
+
return weighted
|