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