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,237 @@
|
|
|
1
|
+
"""Pydantic configuration model for Mannstein instance detection.
|
|
2
|
+
|
|
3
|
+
Serializable via ``model_dump()``. Uses only pydantic + pyyaml — no torch,
|
|
4
|
+
scipy, or numpy at module level.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any, Literal
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
14
|
+
|
|
15
|
+
FORMAT_VERSION = 1
|
|
16
|
+
"""Version of the instance config file layout (``format_version`` in the YAML files)."""
|
|
17
|
+
|
|
18
|
+
_SECTIONS = ("format_version", "mannstein", "pipeline")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_sections(path: str | Path) -> dict[str, Any]:
|
|
22
|
+
"""Read an instance config YAML and check its top-level keys and format version."""
|
|
23
|
+
with Path(path).open() as f:
|
|
24
|
+
raw = yaml.safe_load(f) or {}
|
|
25
|
+
unknown = sorted(set(raw) - set(_SECTIONS))
|
|
26
|
+
if unknown:
|
|
27
|
+
raise ValueError(f"{path}: unknown section(s) {unknown}; expected {list(_SECTIONS)}")
|
|
28
|
+
version = raw.get("format_version", FORMAT_VERSION)
|
|
29
|
+
if version != FORMAT_VERSION:
|
|
30
|
+
raise ValueError(f"{path}: format_version {version!r} is not supported ({FORMAT_VERSION})")
|
|
31
|
+
return raw
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MannsteinConfig(BaseModel):
|
|
35
|
+
"""Settings of the Mannstein step that splits a binary contrail mask into contrails.
|
|
36
|
+
|
|
37
|
+
The defaults are the tuned settings of the default pipeline (``InstancePipelineConfig()``),
|
|
38
|
+
chosen for one contrail per annotator-drawn contrail (pooled mAP over IoU 0.25-0.75).
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
line_filter
|
|
43
|
+
Keep only mask pixels where an oriented line kernel responds above
|
|
44
|
+
``response_threshold`` before splitting. Off by default: at the tuned settings it keeps
|
|
45
|
+
every pixel of the mask and takes most of the run time.
|
|
46
|
+
num_kernels
|
|
47
|
+
Number of oriented line kernels (line filter only).
|
|
48
|
+
kernel_size
|
|
49
|
+
Kernel size in pixels, odd (line filter only).
|
|
50
|
+
sigma1
|
|
51
|
+
Inner Gaussian sigma in pixels (line filter only).
|
|
52
|
+
sigma_delta
|
|
53
|
+
Outer minus inner Gaussian sigma, in pixels (line filter only).
|
|
54
|
+
response_threshold
|
|
55
|
+
Minimum kernel response (line filter only).
|
|
56
|
+
min_pixels
|
|
57
|
+
Minimum contrail size in pixels.
|
|
58
|
+
min_length
|
|
59
|
+
Minimum contrail length in pixels.
|
|
60
|
+
min_r_score
|
|
61
|
+
Minimum straightness, squared before the comparison: a contrail is kept when its
|
|
62
|
+
straightness ((λ1 - λ2) / (λ1 + λ2))², from the eigenvalues of its pixel-coordinate
|
|
63
|
+
covariance, exceeds ``min_r_score²``. The measure does not depend on the direction.
|
|
64
|
+
lookback
|
|
65
|
+
Number of most recent pixels of a growing contrail used to fit its local direction.
|
|
66
|
+
alignment_threshold
|
|
67
|
+
A neighbouring pixel joins a growing contrail when it lies within this many pixels of
|
|
68
|
+
the contrail's local direction.
|
|
69
|
+
fit_avg_size
|
|
70
|
+
Pixels averaged at each end of a contrail when measuring its length, for the
|
|
71
|
+
length/straightness filter and the minimum size of a scored contrail.
|
|
72
|
+
filter_avg_size
|
|
73
|
+
Overrides ``fit_avg_size`` for the length/straightness filter only. ``None`` reuses
|
|
74
|
+
``fit_avg_size``.
|
|
75
|
+
iou_threshold
|
|
76
|
+
Two candidates overlapping more than this IoU are duplicates; the first found is kept.
|
|
77
|
+
score_saturation
|
|
78
|
+
Pixel count at which the size part of the score, tanh(n_pixels / score_saturation),
|
|
79
|
+
approaches 1.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
83
|
+
|
|
84
|
+
line_filter: bool = False
|
|
85
|
+
num_kernels: Annotated[int, Field(gt=0)] = 24
|
|
86
|
+
kernel_size: Annotated[int, Field(gt=0)] = 25
|
|
87
|
+
sigma1: Annotated[float, Field(gt=0.0)] = 3.38996845929638
|
|
88
|
+
sigma_delta: Annotated[float, Field(gt=0.0)] = 0.7008195042725893
|
|
89
|
+
response_threshold: Annotated[float, Field(ge=0.0)] = 0.00012714987768293402
|
|
90
|
+
min_pixels: Annotated[int, Field(gt=0)] = 5
|
|
91
|
+
min_length: Annotated[float, Field(gt=0.0)] = 4.173422506229322
|
|
92
|
+
min_r_score: Annotated[float, Field(ge=0.0, le=1.0)] = 0.07830797096507566
|
|
93
|
+
lookback: Annotated[int, Field(gt=0)] = 53
|
|
94
|
+
alignment_threshold: Annotated[float, Field(gt=0.0)] = 2.4559123612088265
|
|
95
|
+
fit_avg_size: Annotated[int, Field(gt=0)] = 2
|
|
96
|
+
filter_avg_size: Annotated[int, Field(gt=0)] | None = 2
|
|
97
|
+
iou_threshold: Annotated[float, Field(ge=0.0, le=1.0)] = 0.29602142259052816
|
|
98
|
+
score_saturation: Annotated[float, Field(gt=0.0)] = 26.486876651437136
|
|
99
|
+
|
|
100
|
+
@model_validator(mode="after")
|
|
101
|
+
def _validate_kernel_size(self) -> MannsteinConfig:
|
|
102
|
+
"""Ensure kernel_size is odd."""
|
|
103
|
+
if self.kernel_size % 2 == 0:
|
|
104
|
+
raise ValueError(f"kernel_size must be odd, got {self.kernel_size}")
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_dict(cls, d: dict[str, Any]) -> MannsteinConfig:
|
|
109
|
+
"""Build a ``MannsteinConfig`` from a plain dictionary."""
|
|
110
|
+
return cls.model_validate(d)
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_yaml(cls, path: str | Path) -> MannsteinConfig:
|
|
114
|
+
"""Load a ``MannsteinConfig`` from the ``mannstein:`` section of a YAML file.
|
|
115
|
+
|
|
116
|
+
Fields missing from the section keep their defaults; unknown fields are an error.
|
|
117
|
+
|
|
118
|
+
Raises
|
|
119
|
+
------
|
|
120
|
+
ValueError
|
|
121
|
+
If the file has no ``mannstein:`` section, unknown sections or fields, or another
|
|
122
|
+
``format_version``.
|
|
123
|
+
"""
|
|
124
|
+
sections = _read_sections(path)
|
|
125
|
+
if "mannstein" not in sections:
|
|
126
|
+
raise ValueError(f"{path} has no mannstein: section")
|
|
127
|
+
return cls.model_validate({**cls().model_dump(), **sections["mannstein"]})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Mannstein settings of the consensus preset: tuned with scripts/optimize_mannstein.py on each
|
|
131
|
+
# annotator's own mask (train), then chosen on validation predictions for the consensus target
|
|
132
|
+
# (see configs/instance_pipeline_consensus.yaml).
|
|
133
|
+
_CONSENSUS_MANNSTEIN = MannsteinConfig(
|
|
134
|
+
num_kernels=27,
|
|
135
|
+
kernel_size=15,
|
|
136
|
+
sigma1=1.6281154034079715,
|
|
137
|
+
sigma_delta=0.6895797586480001,
|
|
138
|
+
response_threshold=0.00012114447935536606,
|
|
139
|
+
min_pixels=10,
|
|
140
|
+
min_length=3.6267323822860877,
|
|
141
|
+
min_r_score=0.05470144383145452,
|
|
142
|
+
lookback=53,
|
|
143
|
+
alignment_threshold=2.663708517028666,
|
|
144
|
+
fit_avg_size=3,
|
|
145
|
+
filter_avg_size=2,
|
|
146
|
+
iou_threshold=0.41435493420805575,
|
|
147
|
+
score_saturation=18.325171813375093,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class InstancePipelineConfig(BaseModel):
|
|
152
|
+
"""Configuration of the probability-map instance pipeline.
|
|
153
|
+
|
|
154
|
+
The pipeline thresholds a semantic probability map, splits the binary mask into
|
|
155
|
+
contrails with Mannstein, grows each contrail into neighbouring pixels of moderate
|
|
156
|
+
probability, and scores it. The defaults are the tuned pipeline, and their Mannstein part
|
|
157
|
+
is ``MannsteinConfig()``.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
semantic_threshold
|
|
162
|
+
Pixels with probability above this value form the binary mask given to Mannstein.
|
|
163
|
+
grow_threshold
|
|
164
|
+
Growth enters neighbouring pixels with probability above this value.
|
|
165
|
+
grow_steps
|
|
166
|
+
Number of one-pixel growth steps (0 disables growth).
|
|
167
|
+
grow_connectivity
|
|
168
|
+
Neighbourhood used for growth: 4 or 8.
|
|
169
|
+
probability_scores
|
|
170
|
+
Multiply each Mannstein score by the contrail's mean probability.
|
|
171
|
+
mannstein
|
|
172
|
+
Settings of the Mannstein splitting step.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
176
|
+
|
|
177
|
+
semantic_threshold: Annotated[float, Field(gt=0.0, lt=1.0)] = 0.46466753022859164
|
|
178
|
+
grow_threshold: Annotated[float, Field(ge=0.0, lt=1.0)] = 0.3711273368123679
|
|
179
|
+
grow_steps: Annotated[int, Field(ge=0)] = 1
|
|
180
|
+
grow_connectivity: Literal[4, 8] = 8
|
|
181
|
+
probability_scores: bool = True
|
|
182
|
+
mannstein: MannsteinConfig = MannsteinConfig()
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def consensus(cls) -> InstancePipelineConfig:
|
|
186
|
+
"""Return the settings for contrails agreed by most annotators.
|
|
187
|
+
|
|
188
|
+
Same as ``configs/instance_pipeline_consensus.yaml``.
|
|
189
|
+
|
|
190
|
+
No growth: growth widens contrails toward how single annotators draw them, which helps
|
|
191
|
+
when each annotator is the ground truth (the defaults) but not for the consensus.
|
|
192
|
+
"""
|
|
193
|
+
return cls(
|
|
194
|
+
semantic_threshold=0.5,
|
|
195
|
+
grow_threshold=0.4,
|
|
196
|
+
grow_steps=0,
|
|
197
|
+
grow_connectivity=8,
|
|
198
|
+
probability_scores=True,
|
|
199
|
+
mannstein=_CONSENSUS_MANNSTEIN,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def from_dict(cls, d: dict[str, Any]) -> InstancePipelineConfig:
|
|
204
|
+
"""Build an ``InstancePipelineConfig`` from a plain dictionary."""
|
|
205
|
+
return cls.model_validate(d)
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
def from_yaml(cls, path: str | Path) -> InstancePipelineConfig:
|
|
209
|
+
"""Load from a YAML file with ``pipeline:`` and ``mannstein:`` sections.
|
|
210
|
+
|
|
211
|
+
Either section may be missing or partial: what is missing is taken from the preset
|
|
212
|
+
``InstancePipelineConfig()``, so ``mannstein: {num_kernels: 24}`` changes only
|
|
213
|
+
``num_kernels``. Unknown sections or fields are an error, so a typo is never silently
|
|
214
|
+
replaced by a default.
|
|
215
|
+
|
|
216
|
+
Raises
|
|
217
|
+
------
|
|
218
|
+
ValueError
|
|
219
|
+
On unknown sections or fields, invalid values, or another ``format_version``.
|
|
220
|
+
"""
|
|
221
|
+
sections = _read_sections(path)
|
|
222
|
+
preset = cls().model_dump()
|
|
223
|
+
mannstein = {**preset.pop("mannstein"), **sections.get("mannstein", {})}
|
|
224
|
+
return cls.model_validate(
|
|
225
|
+
{**preset, **sections.get("pipeline", {}), "mannstein": mannstein}
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def to_yaml(self, path: str | Path) -> None:
|
|
229
|
+
"""Write ``format_version`` and the ``mannstein:`` and ``pipeline:`` sections."""
|
|
230
|
+
data = self.model_dump()
|
|
231
|
+
mannstein = data.pop("mannstein")
|
|
232
|
+
with Path(path).open("w") as f:
|
|
233
|
+
yaml.safe_dump(
|
|
234
|
+
{"format_version": FORMAT_VERSION, "mannstein": mannstein, "pipeline": data},
|
|
235
|
+
f,
|
|
236
|
+
sort_keys=False,
|
|
237
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Instance masks from your own annotations, and detected contrails as COCO results.
|
|
2
|
+
|
|
3
|
+
The instance tools read only ``instance_masks/<split>/<id>.npy``: an ``(N, H, W, A)`` int8 array
|
|
4
|
+
where 1 marks a pixel of contrail n drawn by annotator a, 0 a pixel it does not cover, and -1
|
|
5
|
+
that annotator a drew no contrail n (``A = 1`` for a single annotator). The two builders below
|
|
6
|
+
write that array from a label image or from per-annotator masks. COCO polygons can be turned
|
|
7
|
+
into masks first with ``pycocotools`` (``COCO.annToMask``).
|
|
8
|
+
|
|
9
|
+
``to_coco_results`` saves detections as COCO result entries with RLE masks: overlapping
|
|
10
|
+
contrails stay separate, and the format is read by standard COCO tools.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def instance_masks_from_label_image(labels: np.ndarray) -> np.ndarray:
|
|
24
|
+
"""Build single-annotator instance masks from a label image.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
labels
|
|
29
|
+
Integer ``(H, W)`` image: 0 is background, every other value one contrail (values need
|
|
30
|
+
not be consecutive). Contrails cannot overlap in a label image.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
np.ndarray
|
|
35
|
+
``(N, H, W, 1)`` int8, contrails in increasing label order.
|
|
36
|
+
"""
|
|
37
|
+
labels = np.asarray(labels)
|
|
38
|
+
if labels.ndim != 2:
|
|
39
|
+
raise ValueError(f"labels must be a 2-D (H, W) image, got shape {labels.shape}")
|
|
40
|
+
ids = [v for v in np.unique(labels) if v != 0]
|
|
41
|
+
raw = np.zeros((len(ids), *labels.shape, 1), dtype=np.int8)
|
|
42
|
+
for n, value in enumerate(ids):
|
|
43
|
+
raw[n, :, :, 0] = labels == value
|
|
44
|
+
return raw
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def instance_masks_from_annotators(
|
|
48
|
+
contrails: Sequence[Sequence[np.ndarray]], shape: tuple[int, int] | None = None
|
|
49
|
+
) -> np.ndarray:
|
|
50
|
+
"""Build instance masks from the contrails each annotator drew.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
contrails
|
|
55
|
+
``contrails[a]`` holds the boolean ``(H, W)`` masks of annotator ``a``'s contrails;
|
|
56
|
+
an annotator may have drawn none. Contrail n of one annotator and contrail n of another
|
|
57
|
+
are unrelated.
|
|
58
|
+
shape
|
|
59
|
+
Image size, required only when nobody drew anything.
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
np.ndarray
|
|
64
|
+
``(N, H, W, A)`` int8 with N the most contrails any annotator drew; -1 where annotator
|
|
65
|
+
a drew fewer than n + 1 contrails.
|
|
66
|
+
|
|
67
|
+
Raises
|
|
68
|
+
------
|
|
69
|
+
ValueError
|
|
70
|
+
If masks have different shapes, or no mask and no ``shape`` is given.
|
|
71
|
+
"""
|
|
72
|
+
masks = [np.asarray(m, dtype=bool) for drawn in contrails for m in drawn]
|
|
73
|
+
shapes = {m.shape for m in masks} | ({tuple(shape)} if shape is not None else set())
|
|
74
|
+
if len(shapes) != 1:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"all masks must have one shape (pass shape= when nobody drew), got {sorted(shapes)}"
|
|
77
|
+
)
|
|
78
|
+
(h, w) = shapes.pop()
|
|
79
|
+
n = max((len(drawn) for drawn in contrails), default=0)
|
|
80
|
+
raw = np.full((n, h, w, len(contrails)), -1, dtype=np.int8)
|
|
81
|
+
for a, drawn in enumerate(contrails):
|
|
82
|
+
for k, mask in enumerate(drawn):
|
|
83
|
+
raw[k, :, :, a] = np.asarray(mask, dtype=bool)
|
|
84
|
+
return raw
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def to_coco_results(
|
|
88
|
+
masks: Sequence[np.ndarray],
|
|
89
|
+
scores: Sequence[float],
|
|
90
|
+
image_id: int | str,
|
|
91
|
+
category_id: int = 1,
|
|
92
|
+
) -> list[dict[str, Any]]:
|
|
93
|
+
"""Turn one scene's detections into COCO result entries with RLE masks.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
masks
|
|
98
|
+
Boolean ``(H, W)`` contrail masks, e.g. from ``detect_instances_from_probabilities``.
|
|
99
|
+
scores
|
|
100
|
+
One score per mask.
|
|
101
|
+
image_id
|
|
102
|
+
Identifier of the scene, copied into every entry.
|
|
103
|
+
category_id
|
|
104
|
+
COCO category of a contrail.
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
list[dict]
|
|
109
|
+
``{"image_id", "category_id", "segmentation": {"size", "counts"}, "score"}`` per
|
|
110
|
+
contrail, ready for ``json.dump`` (RLE counts as text).
|
|
111
|
+
|
|
112
|
+
Raises
|
|
113
|
+
------
|
|
114
|
+
ValueError
|
|
115
|
+
If ``scores`` does not have one entry per mask.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
import pycocotools.mask as mask_utils
|
|
119
|
+
except ImportError as err:
|
|
120
|
+
raise ImportError(
|
|
121
|
+
"to_coco_results needs pycocotools: pip install 'deepcontrail-instance[map]'"
|
|
122
|
+
) from err
|
|
123
|
+
|
|
124
|
+
if len(scores) != len(masks):
|
|
125
|
+
raise ValueError(f"need one score per mask, got {len(scores)} for {len(masks)}")
|
|
126
|
+
results = []
|
|
127
|
+
for mask, score in zip(masks, scores, strict=True):
|
|
128
|
+
rle = mask_utils.encode(np.asfortranarray(np.asarray(mask, dtype=np.uint8)))
|
|
129
|
+
counts = rle["counts"]
|
|
130
|
+
counts = counts.decode() if isinstance(counts, bytes) else counts
|
|
131
|
+
results.append(
|
|
132
|
+
{
|
|
133
|
+
"image_id": image_id,
|
|
134
|
+
"category_id": category_id,
|
|
135
|
+
"segmentation": {"size": list(rle["size"]), "counts": counts},
|
|
136
|
+
"score": float(score),
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
return results
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Labels derived from instance masks: each annotator's mask, agreement, consensus contrails.
|
|
2
|
+
|
|
3
|
+
The instance task needs only ``instance_masks``: each annotator's mask, the agreement map and
|
|
4
|
+
the consensus mask are derived from it (``*_from_instances``), for any number of annotators,
|
|
5
|
+
one included. On OpenContrails they equal the stored ``annotator_masks``, ``probability_masks``
|
|
6
|
+
and ``semantic_masks`` exactly. The consensus contrails (:func:`build_consensus_instances`) are
|
|
7
|
+
the instance-level counterpart of the consensus mask.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from deepcontrail.core.readers import extract_all_annotator_instances
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def partition_gt_instances_by_semantic(
|
|
18
|
+
all_gt: list[list[list[np.ndarray]]],
|
|
19
|
+
semantic_masks: list[np.ndarray],
|
|
20
|
+
min_overlap_fraction: float = 0.5,
|
|
21
|
+
tolerance_px: int = 1,
|
|
22
|
+
) -> tuple[list[list[list[np.ndarray]]], list[list[list[np.ndarray]]]]:
|
|
23
|
+
"""Split ground-truth instances into those lying mostly on the semantic mask and the rest.
|
|
24
|
+
|
|
25
|
+
The first step of :func:`build_consensus_instances`. Always pass the ground-truth
|
|
26
|
+
semantic mask, never a prediction.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
all_gt
|
|
31
|
+
``all_gt[scene][slot]`` is the list of boolean ``(H, W)`` instance masks of one
|
|
32
|
+
annotator slot. Slots are split independently.
|
|
33
|
+
semantic_masks
|
|
34
|
+
One boolean ``(H, W)`` ground-truth semantic mask per scene, in the order of ``all_gt``.
|
|
35
|
+
min_overlap_fraction
|
|
36
|
+
An instance is kept when at least this fraction of its pixels lies inside the mask
|
|
37
|
+
(within ``tolerance_px``); kept instances stay whole. With 0.5, a contrail that only
|
|
38
|
+
crosses or touches a consensus contrail is set aside.
|
|
39
|
+
tolerance_px
|
|
40
|
+
Pixels within this many 4-connected steps of the mask count as inside, so the same
|
|
41
|
+
contrail drawn 1 px off by an annotator is kept. 0 means strictly inside.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
tuple[list[list[list[np.ndarray]]], list[list[list[np.ndarray]]]]
|
|
46
|
+
``(kept, dropped)``, both with the scene and slot structure of ``all_gt``.
|
|
47
|
+
|
|
48
|
+
Raises
|
|
49
|
+
------
|
|
50
|
+
ValueError
|
|
51
|
+
If the number of scenes differs between the two inputs, or
|
|
52
|
+
``min_overlap_fraction`` is not in ``(0, 1]``, or ``tolerance_px`` is negative.
|
|
53
|
+
"""
|
|
54
|
+
if len(all_gt) != len(semantic_masks):
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"need one semantic mask per scene, got {len(semantic_masks)} for {len(all_gt)} scenes"
|
|
57
|
+
)
|
|
58
|
+
if not 0.0 < min_overlap_fraction <= 1.0:
|
|
59
|
+
raise ValueError(f"min_overlap_fraction must be in (0, 1], got {min_overlap_fraction}")
|
|
60
|
+
if tolerance_px < 0:
|
|
61
|
+
raise ValueError(f"tolerance_px must be >= 0, got {tolerance_px}")
|
|
62
|
+
kept: list[list[list[np.ndarray]]] = []
|
|
63
|
+
dropped: list[list[list[np.ndarray]]] = []
|
|
64
|
+
for scene, semantic in zip(all_gt, semantic_masks, strict=True):
|
|
65
|
+
inside = _grow_4connected(np.asarray(semantic, dtype=bool), tolerance_px)
|
|
66
|
+
keep_scene: list[list[np.ndarray]] = []
|
|
67
|
+
drop_scene: list[list[np.ndarray]] = []
|
|
68
|
+
for slot in scene:
|
|
69
|
+
is_kept = [
|
|
70
|
+
bool(m.any())
|
|
71
|
+
and np.count_nonzero(m & inside) >= min_overlap_fraction * np.count_nonzero(m)
|
|
72
|
+
for m in slot
|
|
73
|
+
]
|
|
74
|
+
keep_scene.append([m for m, k in zip(slot, is_kept, strict=True) if k])
|
|
75
|
+
drop_scene.append([m for m, k in zip(slot, is_kept, strict=True) if not k])
|
|
76
|
+
kept.append(keep_scene)
|
|
77
|
+
dropped.append(drop_scene)
|
|
78
|
+
return kept, dropped
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _grow_4connected(mask: np.ndarray, steps: int) -> np.ndarray:
|
|
82
|
+
"""Dilate a boolean mask by ``steps`` 4-connected steps (numpy only)."""
|
|
83
|
+
grown = mask.copy()
|
|
84
|
+
for _ in range(steps):
|
|
85
|
+
step = grown.copy()
|
|
86
|
+
step[1:, :] |= grown[:-1, :]
|
|
87
|
+
step[:-1, :] |= grown[1:, :]
|
|
88
|
+
step[:, 1:] |= grown[:, :-1]
|
|
89
|
+
step[:, :-1] |= grown[:, 1:]
|
|
90
|
+
grown = step
|
|
91
|
+
return grown
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_consensus_instances(
|
|
95
|
+
gt_per_slot: list[list[np.ndarray]],
|
|
96
|
+
semantic_mask: np.ndarray,
|
|
97
|
+
min_overlap_fraction: float = 0.5,
|
|
98
|
+
tolerance_px: int = 1,
|
|
99
|
+
link_iou: float = 0.5,
|
|
100
|
+
) -> list[np.ndarray]:
|
|
101
|
+
"""Build one set of consensus contrail instances for a scene from all annotators.
|
|
102
|
+
|
|
103
|
+
The instance-level counterpart of the consensus semantic mask:
|
|
104
|
+
|
|
105
|
+
1. keep each annotator's instances with at least ``min_overlap_fraction`` of their
|
|
106
|
+
pixels within ``tolerance_px`` of the consensus mask, and clip them to the mask;
|
|
107
|
+
2. link instances of different annotators whose clipped masks reach ``link_iou``;
|
|
108
|
+
3. keep the linked groups drawn by more than half of the scene's annotators;
|
|
109
|
+
4. each kept instance is the union of its group's clipped masks.
|
|
110
|
+
|
|
111
|
+
Instances may share pixels where contrails cross. The result no longer depends on
|
|
112
|
+
which annotator drew what, only on what a majority agreed on.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
gt_per_slot
|
|
117
|
+
``gt_per_slot[slot]`` is one annotator's list of boolean ``(H, W)`` instance masks.
|
|
118
|
+
semantic_mask
|
|
119
|
+
Boolean ``(H, W)`` consensus semantic mask of the scene.
|
|
120
|
+
min_overlap_fraction
|
|
121
|
+
See :func:`partition_gt_instances_by_semantic`.
|
|
122
|
+
tolerance_px
|
|
123
|
+
See :func:`partition_gt_instances_by_semantic`.
|
|
124
|
+
link_iou
|
|
125
|
+
IoU between two clipped instances of different annotators to treat them as the
|
|
126
|
+
same contrail.
|
|
127
|
+
|
|
128
|
+
Returns
|
|
129
|
+
-------
|
|
130
|
+
list[np.ndarray]
|
|
131
|
+
Boolean ``(H, W)`` consensus instance masks (empty when the scene has no consensus
|
|
132
|
+
contrail or no annotator).
|
|
133
|
+
"""
|
|
134
|
+
n_slots = len(gt_per_slot)
|
|
135
|
+
semantic = np.asarray(semantic_mask, dtype=bool)
|
|
136
|
+
if n_slots == 0 or not semantic.any():
|
|
137
|
+
return []
|
|
138
|
+
kept, _ = partition_gt_instances_by_semantic(
|
|
139
|
+
[gt_per_slot], [semantic], min_overlap_fraction, tolerance_px
|
|
140
|
+
)
|
|
141
|
+
nodes = [(slot, m & semantic) for slot, masks in enumerate(kept[0]) for m in masks]
|
|
142
|
+
nodes = [(slot, clipped) for slot, clipped in nodes if clipped.any()]
|
|
143
|
+
|
|
144
|
+
parent = list(range(len(nodes)))
|
|
145
|
+
|
|
146
|
+
def find(i: int) -> int:
|
|
147
|
+
while parent[i] != i:
|
|
148
|
+
parent[i] = parent[parent[i]]
|
|
149
|
+
i = parent[i]
|
|
150
|
+
return i
|
|
151
|
+
|
|
152
|
+
for i, (slot_i, mask_i) in enumerate(nodes):
|
|
153
|
+
for j in range(i + 1, len(nodes)):
|
|
154
|
+
slot_j, mask_j = nodes[j]
|
|
155
|
+
if slot_i == slot_j:
|
|
156
|
+
continue
|
|
157
|
+
union = np.count_nonzero(mask_i | mask_j)
|
|
158
|
+
if union and np.count_nonzero(mask_i & mask_j) / union >= link_iou:
|
|
159
|
+
parent[find(i)] = find(j)
|
|
160
|
+
|
|
161
|
+
groups: dict[int, list[int]] = {}
|
|
162
|
+
for i in range(len(nodes)):
|
|
163
|
+
groups.setdefault(find(i), []).append(i)
|
|
164
|
+
|
|
165
|
+
instances: list[np.ndarray] = []
|
|
166
|
+
for members in groups.values():
|
|
167
|
+
if len({nodes[i][0] for i in members}) * 2 > n_slots:
|
|
168
|
+
instances.append(np.logical_or.reduce([nodes[i][1] for i in members]))
|
|
169
|
+
return instances
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def annotator_masks_from_instances(raw: np.ndarray) -> np.ndarray:
|
|
173
|
+
"""Return each annotator's mask: the union of the contrails they drew.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
raw
|
|
178
|
+
Instance mask array ``(N, H, W, A)`` (1 = pixel of instance n for annotator a).
|
|
179
|
+
|
|
180
|
+
Returns
|
|
181
|
+
-------
|
|
182
|
+
np.ndarray
|
|
183
|
+
``(H, W, A)`` uint8 in ``{0, 1}``; all zeros for a scene without contrails.
|
|
184
|
+
"""
|
|
185
|
+
if raw.shape[0] == 0:
|
|
186
|
+
return np.zeros(raw.shape[1:], dtype=np.uint8)
|
|
187
|
+
return np.any(raw == 1, axis=0).astype(np.uint8)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def agreement_from_instances(raw: np.ndarray) -> np.ndarray:
|
|
191
|
+
"""Return the share of annotators who marked each pixel, ``(H, W)`` float32 in ``[0, 1]``."""
|
|
192
|
+
return annotator_masks_from_instances(raw).mean(axis=-1, dtype=np.float32)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def consensus_mask_from_instances(raw: np.ndarray) -> np.ndarray:
|
|
196
|
+
"""Return the pixels marked by more than half of the annotators, ``(H, W)`` bool."""
|
|
197
|
+
return agreement_from_instances(raw) > 0.5
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def consensus_instances(raw: np.ndarray) -> list[np.ndarray]:
|
|
201
|
+
"""Return the scene's consensus contrails, from its instance masks alone.
|
|
202
|
+
|
|
203
|
+
See :func:`build_consensus_instances`; the consensus mask is
|
|
204
|
+
:func:`consensus_mask_from_instances`. With a single annotator, every contrail they drew
|
|
205
|
+
is kept.
|
|
206
|
+
"""
|
|
207
|
+
if raw.shape[0] == 0:
|
|
208
|
+
return []
|
|
209
|
+
return build_consensus_instances(
|
|
210
|
+
extract_all_annotator_instances(raw), consensus_mask_from_instances(raw)
|
|
211
|
+
)
|