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