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,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
+ )