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,49 @@
|
|
|
1
|
+
"""Instance segmentation of contrails: separate the contrails of a segmentation map.
|
|
2
|
+
|
|
3
|
+
Mannstein-style line detection and region growing turn a binary or probability mask into
|
|
4
|
+
one mask and one score per contrail. Torch-free; scikit-learn is needed only to train a
|
|
5
|
+
``ScoreModel`` (the ``[scores]`` extra).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
|
|
10
|
+
from deepcontrail.instance.bundle import InstanceStep, load_instance_step, save_instance_step
|
|
11
|
+
from deepcontrail.instance.config import InstancePipelineConfig, MannsteinConfig
|
|
12
|
+
from deepcontrail.instance.io import (
|
|
13
|
+
instance_masks_from_annotators,
|
|
14
|
+
instance_masks_from_label_image,
|
|
15
|
+
to_coco_results,
|
|
16
|
+
)
|
|
17
|
+
from deepcontrail.instance.mannstein import detect_instances
|
|
18
|
+
from deepcontrail.instance.pipeline import detect_instances_from_probabilities
|
|
19
|
+
from deepcontrail.instance.refine import grow_instances, probability_weighted_scores
|
|
20
|
+
from deepcontrail.instance.scoring import (
|
|
21
|
+
FEATURES,
|
|
22
|
+
ScoreModel,
|
|
23
|
+
instance_features,
|
|
24
|
+
match_quality,
|
|
25
|
+
training_data,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = version("deepcontrail-instance")
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"FEATURES",
|
|
32
|
+
"InstancePipelineConfig",
|
|
33
|
+
"InstanceStep",
|
|
34
|
+
"MannsteinConfig",
|
|
35
|
+
"ScoreModel",
|
|
36
|
+
"__version__",
|
|
37
|
+
"detect_instances",
|
|
38
|
+
"detect_instances_from_probabilities",
|
|
39
|
+
"grow_instances",
|
|
40
|
+
"instance_features",
|
|
41
|
+
"instance_masks_from_annotators",
|
|
42
|
+
"instance_masks_from_label_image",
|
|
43
|
+
"load_instance_step",
|
|
44
|
+
"match_quality",
|
|
45
|
+
"probability_weighted_scores",
|
|
46
|
+
"save_instance_step",
|
|
47
|
+
"to_coco_results",
|
|
48
|
+
"training_data",
|
|
49
|
+
]
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Building blocks of the instance evaluation and tuning scripts.
|
|
2
|
+
|
|
3
|
+
Private module (leading underscore): it serves the repository scripts in ``scripts/`` and is
|
|
4
|
+
not part of the public API, so it may change between releases.
|
|
5
|
+
|
|
6
|
+
Annotator samples, scene lists and splits, loading predictions, running the detection in
|
|
7
|
+
parallel, and scoring it per annotator sample (pooled COCO mAP over IoU 0.25-0.75, see
|
|
8
|
+
``deepcontrail.instance.metrics``). The scripts in ``scripts/`` are thin command-line
|
|
9
|
+
wrappers around these functions.
|
|
10
|
+
|
|
11
|
+
Only ``instance_masks/<split>/<id>.npy`` is read from a dataset. Parallel runs use the default
|
|
12
|
+
multiprocessing start method, so they also work where ``fork`` is unavailable (Windows) or
|
|
13
|
+
unsafe (macOS).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import multiprocessing
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from deepcontrail.core.readers import (
|
|
27
|
+
binarize_mask,
|
|
28
|
+
extract_all_annotator_instances,
|
|
29
|
+
extract_annotator_instances,
|
|
30
|
+
load_instance_masks,
|
|
31
|
+
load_mask,
|
|
32
|
+
)
|
|
33
|
+
from deepcontrail.instance.labels import annotator_masks_from_instances
|
|
34
|
+
from deepcontrail.instance.mannstein import detect_instances
|
|
35
|
+
from deepcontrail.instance.pipeline import detect_instances_from_probabilities
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import Callable, Sequence
|
|
39
|
+
|
|
40
|
+
from deepcontrail.instance.config import InstancePipelineConfig, MannsteinConfig
|
|
41
|
+
from deepcontrail.instance.scoring import ScoreModel
|
|
42
|
+
|
|
43
|
+
Detections = tuple[list[np.ndarray], list[float]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_workers() -> int:
|
|
47
|
+
"""Return a safe default number of worker processes: at most 8, at most the CPU count."""
|
|
48
|
+
return max(1, min(8, os.cpu_count() or 1))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Sample:
|
|
53
|
+
"""One annotator on one scene: their semantic mask and their instances (GT)."""
|
|
54
|
+
|
|
55
|
+
stem: str
|
|
56
|
+
slot: int
|
|
57
|
+
annotator_mask: np.ndarray
|
|
58
|
+
gt: list[np.ndarray]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_samples(instance_masks: np.ndarray, stem: str = "") -> list[Sample]:
|
|
62
|
+
"""Build every annotator sample of one scene, including empty ones.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
instance_masks
|
|
67
|
+
``(N, H, W, A)`` instance masks; 1 = pixel of instance n for annotator a, -1 =
|
|
68
|
+
annotator a did not draw instance n (never part of the GT). ``A`` may be 1.
|
|
69
|
+
stem
|
|
70
|
+
Scene id, kept on each sample for reporting.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
list[Sample]
|
|
75
|
+
One sample per annotator. Its mask is the union of the annotator's contrails. A sample
|
|
76
|
+
with an empty GT is skipped by the scorer.
|
|
77
|
+
"""
|
|
78
|
+
masks = annotator_masks_from_instances(instance_masks)
|
|
79
|
+
return [
|
|
80
|
+
Sample(stem, slot, masks[..., slot], extract_annotator_instances(instance_masks, slot))
|
|
81
|
+
for slot in range(instance_masks.shape[-1])
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def list_scenes(root: str | Path, split: str) -> list[str]:
|
|
86
|
+
"""Return the sorted scene ids of ``<root>/instance_masks/<split>/``.
|
|
87
|
+
|
|
88
|
+
Raises
|
|
89
|
+
------
|
|
90
|
+
FileNotFoundError
|
|
91
|
+
If the folder does not exist.
|
|
92
|
+
"""
|
|
93
|
+
folder = Path(root) / "instance_masks" / split
|
|
94
|
+
if not folder.is_dir():
|
|
95
|
+
raise FileNotFoundError(f"directory not found: {folder}")
|
|
96
|
+
return sorted(p.stem for p in folder.glob("*.npy"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def split_scenes(
|
|
100
|
+
stems: Sequence[str],
|
|
101
|
+
seed: int,
|
|
102
|
+
n_search: int | None = None,
|
|
103
|
+
n_holdout: int | None = None,
|
|
104
|
+
) -> tuple[list[str], list[str]]:
|
|
105
|
+
"""Split scene ids into disjoint search and holdout sets, reproducibly.
|
|
106
|
+
|
|
107
|
+
The ids are shuffled with ``seed``; the first ``n_search`` form the search set and the next
|
|
108
|
+
``n_holdout`` the holdout. By default the search set is half of the scenes and the holdout
|
|
109
|
+
is the rest.
|
|
110
|
+
|
|
111
|
+
Raises
|
|
112
|
+
------
|
|
113
|
+
ValueError
|
|
114
|
+
If either set would be empty, or if more scenes are asked for than exist.
|
|
115
|
+
"""
|
|
116
|
+
n = len(stems)
|
|
117
|
+
n_search = n // 2 if n_search is None else n_search
|
|
118
|
+
n_holdout = n - n_search if n_holdout is None else n_holdout
|
|
119
|
+
if n_search <= 0 or n_holdout <= 0:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"{n} scenes give {n_search} search and {n_holdout} holdout scenes; both must be "
|
|
122
|
+
"at least 1 (the holdout is what the results are judged on)"
|
|
123
|
+
)
|
|
124
|
+
if n_search + n_holdout > n:
|
|
125
|
+
raise ValueError(f"need {n_search} + {n_holdout} scenes, found {n}")
|
|
126
|
+
shuffled = [stems[int(i)] for i in np.random.default_rng(seed).permutation(n)]
|
|
127
|
+
return shuffled[:n_search], shuffled[n_search : n_search + n_holdout]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def select_annotator_samples(
|
|
131
|
+
root: str | Path, n_samples: int, n_holdout: int, seed: int, split: str = "train"
|
|
132
|
+
) -> tuple[list[Sample], list[Sample]]:
|
|
133
|
+
"""Pick non-empty annotator samples for a search and a holdout, scene by scene.
|
|
134
|
+
|
|
135
|
+
Scenes are shuffled with ``seed`` and walked in order; each scene's non-empty annotator
|
|
136
|
+
samples go to the search set until it holds at least ``n_samples``, then to the holdout
|
|
137
|
+
until it holds at least ``n_holdout``. A scene is never split.
|
|
138
|
+
|
|
139
|
+
Raises
|
|
140
|
+
------
|
|
141
|
+
ValueError
|
|
142
|
+
If the split has too few non-empty annotator samples.
|
|
143
|
+
"""
|
|
144
|
+
root = Path(root)
|
|
145
|
+
stems = list_scenes(root, split)
|
|
146
|
+
order = np.random.default_rng(seed).permutation(len(stems))
|
|
147
|
+
search: list[Sample] = []
|
|
148
|
+
holdout: list[Sample] = []
|
|
149
|
+
for i in order:
|
|
150
|
+
if len(holdout) >= n_holdout:
|
|
151
|
+
break
|
|
152
|
+
raw = load_instance_masks(root / "instance_masks" / split / f"{stems[i]}.npy")
|
|
153
|
+
scene = [s for s in build_samples(raw, stems[i]) if s.gt]
|
|
154
|
+
(search if len(search) < n_samples else holdout).extend(scene)
|
|
155
|
+
if len(search) < n_samples or len(holdout) < n_holdout:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"need {n_samples} + {n_holdout} non-empty annotator samples, found "
|
|
158
|
+
f"{len(search)} + {len(holdout)}"
|
|
159
|
+
)
|
|
160
|
+
return search, holdout
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def load_prediction(path: str | Path) -> np.ndarray:
|
|
164
|
+
"""Load one scene's semantic prediction as a float32 map in ``[0, 1]``.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
path
|
|
169
|
+
A ``.npy`` file with probabilities of shape ``(H, W)``, or a ``.png`` grayscale
|
|
170
|
+
mask where any non-zero pixel is foreground (returned as 0.0/1.0).
|
|
171
|
+
|
|
172
|
+
Raises
|
|
173
|
+
------
|
|
174
|
+
ValueError
|
|
175
|
+
If the suffix is neither ``.npy`` nor ``.png``, if a ``.npy`` array is not 2-D, holds
|
|
176
|
+
non-finite values, or has values outside ``[0, 1]`` (tolerance 1e-3).
|
|
177
|
+
"""
|
|
178
|
+
path = Path(path)
|
|
179
|
+
suffix = path.suffix.lower()
|
|
180
|
+
if suffix == ".png":
|
|
181
|
+
return binarize_mask(load_mask(path)).astype(np.float32)
|
|
182
|
+
if suffix == ".npy":
|
|
183
|
+
probs = np.load(path)
|
|
184
|
+
if probs.ndim != 2:
|
|
185
|
+
raise ValueError(f"prediction {path} must be a 2-D (H, W) array, got {probs.shape}")
|
|
186
|
+
if not np.all(np.isfinite(probs)):
|
|
187
|
+
raise ValueError(f"prediction {path} has non-finite values (NaN or inf)")
|
|
188
|
+
if np.any(probs < -1e-3) or np.any(probs > 1.0 + 1e-3):
|
|
189
|
+
raise ValueError(f"prediction {path} has values outside [0, 1] (tolerance 1e-3)")
|
|
190
|
+
return probs.astype(np.float32)
|
|
191
|
+
raise ValueError(f"unsupported prediction suffix {suffix!r}; expected .npy or .png")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def prediction_path(pred_dir: str | Path, stem: str) -> Path:
|
|
195
|
+
"""Return ``<stem>.npy`` if present, else ``<stem>.png`` in ``pred_dir``."""
|
|
196
|
+
npy = Path(pred_dir) / f"{stem}.npy"
|
|
197
|
+
return npy if npy.exists() else Path(pred_dir) / f"{stem}.png"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def load_predictions(
|
|
201
|
+
pred_dir: str | Path, root: str | Path, stems: Sequence[str], split: str
|
|
202
|
+
) -> tuple[list[np.ndarray], list[list[list[np.ndarray]]]]:
|
|
203
|
+
"""Load the predictions of ``stems`` and their GT contrails per annotator.
|
|
204
|
+
|
|
205
|
+
Raises
|
|
206
|
+
------
|
|
207
|
+
FileNotFoundError
|
|
208
|
+
If a scene has no prediction file, or no instance masks in ``split``.
|
|
209
|
+
ValueError
|
|
210
|
+
If a prediction is not the size of its masks.
|
|
211
|
+
"""
|
|
212
|
+
instance_dir = Path(root) / "instance_masks" / split
|
|
213
|
+
missing = [s for s in stems if not prediction_path(pred_dir, s).exists()]
|
|
214
|
+
if missing:
|
|
215
|
+
raise FileNotFoundError(
|
|
216
|
+
f"{len(missing)} of {len(stems)} scenes have no prediction in {pred_dir}: "
|
|
217
|
+
+ ", ".join(missing[:5])
|
|
218
|
+
)
|
|
219
|
+
no_gt = [s for s in stems if not (instance_dir / f"{s}.npy").exists()]
|
|
220
|
+
if no_gt:
|
|
221
|
+
raise FileNotFoundError(
|
|
222
|
+
f"{len(no_gt)} scenes have no instance masks in {instance_dir}: " + ", ".join(no_gt[:5])
|
|
223
|
+
)
|
|
224
|
+
probs, gts = [], []
|
|
225
|
+
for stem in stems:
|
|
226
|
+
prob = load_prediction(prediction_path(pred_dir, stem))
|
|
227
|
+
raw = load_instance_masks(instance_dir / f"{stem}.npy")
|
|
228
|
+
check_size(prob, raw.shape[1:3], stem)
|
|
229
|
+
probs.append(prob)
|
|
230
|
+
gts.append(extract_all_annotator_instances(raw))
|
|
231
|
+
return probs, gts
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def check_size(prediction: np.ndarray, mask_shape: tuple[int, ...], stem: str) -> None:
|
|
235
|
+
"""Raise if a prediction is not on the pixel grid of its masks.
|
|
236
|
+
|
|
237
|
+
Raises
|
|
238
|
+
------
|
|
239
|
+
ValueError
|
|
240
|
+
If the shapes differ.
|
|
241
|
+
"""
|
|
242
|
+
if tuple(prediction.shape) != tuple(mask_shape):
|
|
243
|
+
h, w = prediction.shape
|
|
244
|
+
mh, mw = mask_shape
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"prediction of {stem} is {h}x{w} but its masks are {mh}x{mw}; predictions must be "
|
|
247
|
+
"on the pixel grid of the masks"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
_WORKER: dict[str, Any] = {}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _init_worker(state: dict[str, Any]) -> None:
|
|
255
|
+
_WORKER.clear()
|
|
256
|
+
_WORKER.update(state)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _detect_probabilities(probs: np.ndarray) -> Detections:
|
|
260
|
+
return detect_instances_from_probabilities(
|
|
261
|
+
probs, _WORKER["config"], score_model=_WORKER.get("score_model")
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _detect_mask(mask: np.ndarray) -> Detections:
|
|
266
|
+
return detect_instances(mask, _WORKER["config"])
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _run_parallel(
|
|
270
|
+
fn: Callable[[np.ndarray], Detections],
|
|
271
|
+
items: Sequence[np.ndarray],
|
|
272
|
+
state: dict[str, Any],
|
|
273
|
+
workers: int,
|
|
274
|
+
) -> list[Detections]:
|
|
275
|
+
"""Apply ``fn`` to every item, in a process pool when ``workers > 1``.
|
|
276
|
+
|
|
277
|
+
``state`` (small: configs) reaches each worker once through the pool initializer; the
|
|
278
|
+
items are sent with the tasks.
|
|
279
|
+
"""
|
|
280
|
+
if workers <= 1 or len(items) <= 1:
|
|
281
|
+
_init_worker(state)
|
|
282
|
+
return [fn(item) for item in items]
|
|
283
|
+
with multiprocessing.Pool(workers, initializer=_init_worker, initargs=(state,)) as pool:
|
|
284
|
+
return pool.map(fn, items, chunksize=4)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def detect_all(
|
|
288
|
+
probs: Sequence[np.ndarray],
|
|
289
|
+
config: InstancePipelineConfig,
|
|
290
|
+
workers: int,
|
|
291
|
+
score_model: ScoreModel | None = None,
|
|
292
|
+
) -> list[Detections]:
|
|
293
|
+
"""Run ``detect_instances_from_probabilities`` on every map (optionally re-scored)."""
|
|
294
|
+
state = {"config": config, "score_model": score_model}
|
|
295
|
+
return _run_parallel(_detect_probabilities, probs, state, workers)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def detect_all_masks(
|
|
299
|
+
masks: Sequence[np.ndarray], config: MannsteinConfig, workers: int
|
|
300
|
+
) -> list[Detections]:
|
|
301
|
+
"""Run plain Mannstein (``detect_instances``) on every binary mask."""
|
|
302
|
+
return _run_parallel(_detect_mask, masks, {"config": config}, workers)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def evaluate_annotator_masks(
|
|
306
|
+
samples: Sequence[Sample],
|
|
307
|
+
config: MannsteinConfig,
|
|
308
|
+
workers: int,
|
|
309
|
+
per_threshold: bool = False,
|
|
310
|
+
match_iou: float = 0.5,
|
|
311
|
+
) -> dict[str, Any]:
|
|
312
|
+
"""Run plain Mannstein on each sample's own annotator mask and score it (pooled mAP).
|
|
313
|
+
|
|
314
|
+
Returns
|
|
315
|
+
-------
|
|
316
|
+
dict[str, Any]
|
|
317
|
+
The result of ``evaluate_annotator_samples`` plus ``mean_pred_instances``.
|
|
318
|
+
"""
|
|
319
|
+
from deepcontrail.instance.metrics import evaluate_annotator_samples
|
|
320
|
+
|
|
321
|
+
out = detect_all_masks([s.annotator_mask for s in samples], config, workers)
|
|
322
|
+
preds = [o[0] for o in out]
|
|
323
|
+
result = evaluate_annotator_samples(
|
|
324
|
+
preds,
|
|
325
|
+
[s.gt for s in samples],
|
|
326
|
+
[o[1] for o in out],
|
|
327
|
+
match_iou=match_iou,
|
|
328
|
+
per_threshold=per_threshold,
|
|
329
|
+
)
|
|
330
|
+
result["mean_pred_instances"] = float(np.mean([len(p) for p in preds])) if preds else 0.0
|
|
331
|
+
return result
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def evaluate_pipeline(
|
|
335
|
+
probs: Sequence[np.ndarray],
|
|
336
|
+
gts: Sequence[Sequence[Sequence[np.ndarray]]],
|
|
337
|
+
config: InstancePipelineConfig,
|
|
338
|
+
workers: int,
|
|
339
|
+
match_iou: float = 0.5,
|
|
340
|
+
per_threshold: bool = False,
|
|
341
|
+
consensus: Sequence[Sequence[np.ndarray]] | None = None,
|
|
342
|
+
score_model: ScoreModel | None = None,
|
|
343
|
+
) -> dict[str, Any]:
|
|
344
|
+
"""Run the pipeline on every map and score it (pooled mAP).
|
|
345
|
+
|
|
346
|
+
Parameters
|
|
347
|
+
----------
|
|
348
|
+
probs
|
|
349
|
+
Probability maps, one per scene.
|
|
350
|
+
gts
|
|
351
|
+
``gts[i][a]`` holds the GT masks annotator ``a`` drew on scene ``i``.
|
|
352
|
+
config
|
|
353
|
+
Pipeline configuration.
|
|
354
|
+
workers
|
|
355
|
+
Worker processes (1 runs in-process).
|
|
356
|
+
match_iou
|
|
357
|
+
IoU threshold of the reference P/R/F1 matching.
|
|
358
|
+
per_threshold
|
|
359
|
+
Also return the AP at each IoU threshold.
|
|
360
|
+
consensus
|
|
361
|
+
If given, ``consensus[i]`` holds the consensus instances of scene ``i`` and each scene
|
|
362
|
+
is scored once against them instead of once per annotator.
|
|
363
|
+
score_model
|
|
364
|
+
Optional learned scores.
|
|
365
|
+
|
|
366
|
+
Returns
|
|
367
|
+
-------
|
|
368
|
+
dict[str, Any]
|
|
369
|
+
``evaluate_annotator_samples`` result (``map``, reference ``f1`` ...) plus
|
|
370
|
+
``mean_pred_instances`` per scene. Annotators who drew nothing are skipped.
|
|
371
|
+
"""
|
|
372
|
+
from deepcontrail.instance.metrics import evaluate_annotator_samples
|
|
373
|
+
|
|
374
|
+
detections = detect_all(probs, config, workers, score_model)
|
|
375
|
+
if consensus is not None:
|
|
376
|
+
preds = [d[0] for d in detections]
|
|
377
|
+
scores = [d[1] for d in detections]
|
|
378
|
+
targets = [list(c) for c in consensus]
|
|
379
|
+
else:
|
|
380
|
+
preds, scores, targets = [], [], []
|
|
381
|
+
for (masks, scene_scores), scene_gt in zip(detections, gts, strict=True):
|
|
382
|
+
for slot_gt in scene_gt:
|
|
383
|
+
preds.append(masks)
|
|
384
|
+
scores.append(scene_scores)
|
|
385
|
+
targets.append(list(slot_gt))
|
|
386
|
+
result = evaluate_annotator_samples(
|
|
387
|
+
preds, targets, scores, match_iou=match_iou, per_threshold=per_threshold
|
|
388
|
+
)
|
|
389
|
+
result["mean_pred_instances"] = (
|
|
390
|
+
float(np.mean([len(d[0]) for d in detections])) if detections else 0.0
|
|
391
|
+
)
|
|
392
|
+
return result
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Direction-following region growing that splits a binary mask into contrail regions.
|
|
2
|
+
|
|
3
|
+
Private module (leading underscore); not exported from ``deepcontrail.instance``.
|
|
4
|
+
|
|
5
|
+
Seeds walk the mask in row-major order. From each seed not already claimed by an earlier
|
|
6
|
+
region, a breadth-first flood grows along 8-connected neighbours that stay aligned with the
|
|
7
|
+
region's local direction, until the region ends. Each region is then tested for minimum size
|
|
8
|
+
and linearity (:func:`deepcontrail.instance._shape.is_long_and_straight`). A pixel may belong
|
|
9
|
+
to more than one region where contrails cross; only seeds are skipped once claimed.
|
|
10
|
+
|
|
11
|
+
The mask is padded by one background pixel on every side and worked on as flat indices, so the
|
|
12
|
+
eight neighbours are constant offsets with no bounds check. Region membership is an int32
|
|
13
|
+
"owner stamp" per pixel, and the local alignment direction is kept as exact integer running
|
|
14
|
+
sums over the trailing ``lookback`` window, updated in constant time as the region grows.
|
|
15
|
+
|
|
16
|
+
This is the region-growing step of the Mannstein-style method (Mannstein, Meyer & Wendling,
|
|
17
|
+
1999, Int. J. Remote Sensing 20(8), 1641-1660, doi:10.1080/014311699212650).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections import deque
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from deepcontrail.instance._shape import is_long_and_straight
|
|
27
|
+
|
|
28
|
+
# 8-neighbour directions examined in this exact order for every dequeued pixel: (dr, dc).
|
|
29
|
+
_OFFSETS = ((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Region:
|
|
33
|
+
"""A growing region: its accepted pixels and a running alignment-window fit.
|
|
34
|
+
|
|
35
|
+
Pixels are stored as flat indices into the padded mask. The alignment window keeps exact
|
|
36
|
+
integer running sums (n, Σr, Σc, Σr², Σc², Σrc) over the last ``lookback`` accepted pixels,
|
|
37
|
+
so the local least-squares direction is recomputed in constant time when the region grows.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
__slots__ = (
|
|
41
|
+
"branch",
|
|
42
|
+
"dirty",
|
|
43
|
+
"flat",
|
|
44
|
+
"intercept",
|
|
45
|
+
"lookback",
|
|
46
|
+
"size",
|
|
47
|
+
"slope",
|
|
48
|
+
"sum_c",
|
|
49
|
+
"sum_c2",
|
|
50
|
+
"sum_r",
|
|
51
|
+
"sum_r2",
|
|
52
|
+
"sum_rc",
|
|
53
|
+
"width",
|
|
54
|
+
"window",
|
|
55
|
+
"window_n",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def __init__(self, flat0: int, row0: int, col0: int, lookback: int, width: int) -> None:
|
|
59
|
+
self.flat = [flat0]
|
|
60
|
+
self.size = 1
|
|
61
|
+
self.sum_r = row0
|
|
62
|
+
self.sum_c = col0
|
|
63
|
+
self.sum_r2 = row0 * row0
|
|
64
|
+
self.sum_c2 = col0 * col0
|
|
65
|
+
self.sum_rc = row0 * col0
|
|
66
|
+
self.window = deque((flat0,))
|
|
67
|
+
self.window_n = 1
|
|
68
|
+
self.branch = 0
|
|
69
|
+
self.slope = 0.0
|
|
70
|
+
self.intercept = 0.0
|
|
71
|
+
self.dirty = True
|
|
72
|
+
self.lookback = lookback
|
|
73
|
+
self.width = width
|
|
74
|
+
|
|
75
|
+
def add(self, flat: int, row: int, col: int) -> None:
|
|
76
|
+
"""Accept a pixel and fold it into the running alignment window."""
|
|
77
|
+
self.flat.append(flat)
|
|
78
|
+
self.size += 1
|
|
79
|
+
self.window.append(flat)
|
|
80
|
+
self.sum_r += row
|
|
81
|
+
self.sum_c += col
|
|
82
|
+
self.sum_r2 += row * row
|
|
83
|
+
self.sum_c2 += col * col
|
|
84
|
+
self.sum_rc += row * col
|
|
85
|
+
self.window_n += 1
|
|
86
|
+
if self.window_n > self.lookback:
|
|
87
|
+
oldest = self.window.popleft()
|
|
88
|
+
oldest_r = oldest // self.width - 1
|
|
89
|
+
oldest_c = oldest % self.width - 1
|
|
90
|
+
self.sum_r -= oldest_r
|
|
91
|
+
self.sum_c -= oldest_c
|
|
92
|
+
self.sum_r2 -= oldest_r * oldest_r
|
|
93
|
+
self.sum_c2 -= oldest_c * oldest_c
|
|
94
|
+
self.sum_rc -= oldest_r * oldest_c
|
|
95
|
+
self.window_n -= 1
|
|
96
|
+
self.dirty = True
|
|
97
|
+
|
|
98
|
+
def refresh_fit(self) -> None:
|
|
99
|
+
"""Recompute the cached local direction from the running window sums.
|
|
100
|
+
|
|
101
|
+
Branch 0 fits rows on columns (columns vary more), branch 1 fits columns on rows. With
|
|
102
|
+
no spread in the regressor the slope is ``0.0`` and the intercept is the regressand mean.
|
|
103
|
+
"""
|
|
104
|
+
n = self.window_n
|
|
105
|
+
var_c = n * self.sum_c2 - self.sum_c * self.sum_c
|
|
106
|
+
var_r = n * self.sum_r2 - self.sum_r * self.sum_r
|
|
107
|
+
fit_rows_on_cols = var_c > var_r if var_c != var_r else self._tie_prefers_cols()
|
|
108
|
+
if fit_rows_on_cols:
|
|
109
|
+
self.branch = 0
|
|
110
|
+
slope = (n * self.sum_rc - self.sum_r * self.sum_c) / var_c
|
|
111
|
+
self.slope = slope
|
|
112
|
+
self.intercept = (self.sum_r - slope * self.sum_c) / n
|
|
113
|
+
else:
|
|
114
|
+
self.branch = 1
|
|
115
|
+
if var_r == 0:
|
|
116
|
+
self.slope = 0.0
|
|
117
|
+
self.intercept = self.sum_c / n
|
|
118
|
+
else:
|
|
119
|
+
slope = (n * self.sum_rc - self.sum_r * self.sum_c) / var_r
|
|
120
|
+
self.slope = slope
|
|
121
|
+
self.intercept = (self.sum_c - slope * self.sum_r) / n
|
|
122
|
+
self.dirty = False
|
|
123
|
+
|
|
124
|
+
def _tie_prefers_cols(self) -> bool:
|
|
125
|
+
"""Break an exact spread tie between the two axes.
|
|
126
|
+
|
|
127
|
+
When the window's row and column spreads are exactly equal, either axis fits equally
|
|
128
|
+
well. The choice follows the float comparison ``std(cols) > std(rows)`` over the window,
|
|
129
|
+
so that results stay identical to the published v0.1.0 instance step and the score model
|
|
130
|
+
trained on its output. Exact ties are rare, so this costs nothing measurable.
|
|
131
|
+
"""
|
|
132
|
+
window = np.asarray(self.window, dtype=np.int64)
|
|
133
|
+
rows = (window // self.width - 1).astype(float)
|
|
134
|
+
cols = (window % self.width - 1).astype(float)
|
|
135
|
+
return bool(np.std(cols) > np.std(rows))
|
|
136
|
+
|
|
137
|
+
def accepts(self, row: int, col: int, threshold: float) -> bool:
|
|
138
|
+
"""Whether a candidate pixel continues the region's local direction.
|
|
139
|
+
|
|
140
|
+
Fewer than five accumulated pixels are always accepted (bootstrap). Otherwise the cached
|
|
141
|
+
local direction predicts the candidate's coordinate along the more-variant axis and it is
|
|
142
|
+
accepted when the deviation is within ``threshold``.
|
|
143
|
+
"""
|
|
144
|
+
if self.size < 5:
|
|
145
|
+
return True
|
|
146
|
+
if self.dirty:
|
|
147
|
+
self.refresh_fit()
|
|
148
|
+
if self.branch == 0:
|
|
149
|
+
return abs(self.slope * col + self.intercept - row) < threshold
|
|
150
|
+
return abs(self.slope * row + self.intercept - col) < threshold
|
|
151
|
+
|
|
152
|
+
def coords(self) -> tuple[np.ndarray, np.ndarray]:
|
|
153
|
+
"""(rows, cols) arrays of the accepted pixels in acceptance order."""
|
|
154
|
+
flat = np.asarray(self.flat, dtype=np.int64)
|
|
155
|
+
return flat // self.width - 1, flat % self.width - 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def grow_regions(
|
|
159
|
+
binary_mask: np.ndarray,
|
|
160
|
+
min_pixels: int,
|
|
161
|
+
min_length: float,
|
|
162
|
+
min_r_score: float,
|
|
163
|
+
lookback: int,
|
|
164
|
+
alignment_threshold: float,
|
|
165
|
+
avg_size: int,
|
|
166
|
+
) -> list[tuple[np.ndarray, np.ndarray]]:
|
|
167
|
+
"""Grow aligned regions out of a binary mask and keep the straight, long ones.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
binary_mask
|
|
172
|
+
2D boolean or ``{0, 1}`` array of shape ``(H, W)``.
|
|
173
|
+
min_pixels
|
|
174
|
+
Minimum pixel count to keep a region.
|
|
175
|
+
min_length
|
|
176
|
+
Minimum region length for the linearity check.
|
|
177
|
+
min_r_score
|
|
178
|
+
Straightness threshold, squared before the comparison.
|
|
179
|
+
lookback
|
|
180
|
+
Trailing window used for the local alignment regression.
|
|
181
|
+
alignment_threshold
|
|
182
|
+
Maximum allowed deviation for a neighbour to join a growing region.
|
|
183
|
+
avg_size
|
|
184
|
+
Endpoint averaging window for the length check.
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
list[tuple[np.ndarray, np.ndarray]]
|
|
189
|
+
``(rows, cols)`` coordinate arrays of the kept regions, in seed order and pixels in
|
|
190
|
+
acceptance order.
|
|
191
|
+
"""
|
|
192
|
+
h, w = binary_mask.shape
|
|
193
|
+
padded = np.zeros((h + 2, w + 2), dtype=bool)
|
|
194
|
+
padded[1:-1, 1:-1] = binary_mask
|
|
195
|
+
mask = padded.ravel()
|
|
196
|
+
width = w + 2
|
|
197
|
+
stamp = np.zeros(mask.size, dtype=np.int32)
|
|
198
|
+
# (flat offset, dr, dc) per neighbour, in the required examination order.
|
|
199
|
+
neighbors = tuple((dr * width + dc, dr, dc) for dr, dc in _OFFSETS)
|
|
200
|
+
|
|
201
|
+
contrails: list[tuple[np.ndarray, np.ndarray]] = []
|
|
202
|
+
region_num = 0
|
|
203
|
+
for seed in np.nonzero(mask)[0]:
|
|
204
|
+
seed = int(seed)
|
|
205
|
+
if stamp[seed] != 0:
|
|
206
|
+
continue
|
|
207
|
+
region_num += 1
|
|
208
|
+
region = _Region(seed, seed // width - 1, seed % width - 1, lookback, width)
|
|
209
|
+
stamp[seed] = region_num
|
|
210
|
+
queue: deque[int] = deque((seed,))
|
|
211
|
+
|
|
212
|
+
while queue:
|
|
213
|
+
cur = int(queue.popleft())
|
|
214
|
+
cur_r = cur // width - 1
|
|
215
|
+
cur_c = cur % width - 1
|
|
216
|
+
for offset, d_r, d_c in neighbors:
|
|
217
|
+
flat = cur + offset
|
|
218
|
+
if not mask[flat]:
|
|
219
|
+
continue
|
|
220
|
+
if stamp[flat] == region_num:
|
|
221
|
+
continue
|
|
222
|
+
n_r = cur_r + d_r
|
|
223
|
+
n_c = cur_c + d_c
|
|
224
|
+
if not region.accepts(n_r, n_c, alignment_threshold):
|
|
225
|
+
continue
|
|
226
|
+
stamp[flat] = region_num
|
|
227
|
+
region.add(flat, n_r, n_c)
|
|
228
|
+
queue.append(flat)
|
|
229
|
+
|
|
230
|
+
if region.size < min_pixels:
|
|
231
|
+
continue
|
|
232
|
+
rows_arr, cols_arr = region.coords()
|
|
233
|
+
if is_long_and_straight(rows_arr, cols_arr, min_r_score, min_length, avg_size):
|
|
234
|
+
contrails.append((rows_arr, cols_arr))
|
|
235
|
+
|
|
236
|
+
return contrails
|