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,158 @@
1
+ """Oriented difference-of-Gaussians line kernels and oriented line detection.
2
+
3
+ Private module (leading underscore); not exported from ``deepcontrail.instance``.
4
+
5
+ A contrail is a thin, long, bright line. For each of ``num_kernels`` evenly spaced
6
+ orientations a difference-of-Gaussians kernel responds strongly where a line of that
7
+ orientation runs through the pixel. This is the line filter of the Mannstein-style method
8
+ (Mannstein, Meyer & Wendling, 1999, Int. J. Remote Sensing 20(8), 1641-1660,
9
+ doi:10.1080/014311699212650); it is optional and off by default.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from functools import lru_cache
15
+
16
+ import numpy as np
17
+ from scipy.signal import oaconvolve
18
+
19
+ # A kernel is a difference of two Gaussians of the signed distance ``d`` to its axis. The two
20
+ # sigmas, in kernel-cell units, are sigma1/size and (sigma1 + sigma_delta)/size.
21
+
22
+
23
+ def line_orientations(num_kernels: int) -> np.ndarray:
24
+ """Return the ``num_kernels`` evenly spaced orientations in ``[0, 180)`` degrees.
25
+
26
+ Parameters
27
+ ----------
28
+ num_kernels
29
+ Number of orientations.
30
+
31
+ Returns
32
+ -------
33
+ np.ndarray
34
+ Orientations in degrees, ``180 * k / num_kernels`` for ``k = 0 .. num_kernels - 1``.
35
+ """
36
+ return np.linspace(0.0, 180.0, num_kernels, endpoint=False)
37
+
38
+
39
+ def _balance(diff: np.ndarray) -> np.ndarray:
40
+ """Scale a zero-sum kernel so its positive lobe sums to +1 and its negative lobe to -1.
41
+
42
+ Each weight is divided by the sum of its own lobe: positives by the positive total, the rest
43
+ by the (positive) magnitude of the negative total. A kernel with no negative (or no positive)
44
+ weights is returned unchanged.
45
+
46
+ Parameters
47
+ ----------
48
+ diff
49
+ 2D array of the kernel's raw weights.
50
+
51
+ Returns
52
+ -------
53
+ np.ndarray
54
+ Rescaled kernel, same shape as ``diff``.
55
+ """
56
+ positive_total = float(np.sum(np.where(diff > 0.0, diff, 0.0)))
57
+ negative_total = float(np.sum(np.where(diff < 0.0, -diff, 0.0)))
58
+ if positive_total == 0.0 or negative_total == 0.0:
59
+ return diff
60
+ normaliser = np.where(diff > 0.0, positive_total, negative_total)
61
+ return diff / normaliser
62
+
63
+
64
+ def _kernel_cells(size: int) -> tuple[np.ndarray, np.ndarray]:
65
+ """Column and row coordinates of a ``size`` x ``size`` grid, meshgrid 'xy' indexing."""
66
+ axis = np.linspace(-1.0, 1.0, size)
67
+ cols, rows = np.meshgrid(axis, axis) # cols vary along axis 1, rows along axis 0
68
+ return cols, rows
69
+
70
+
71
+ @lru_cache(maxsize=16)
72
+ def build_kernels(
73
+ num_kernels: int, kernel_size: int, sigma1: float, sigma_delta: float
74
+ ) -> np.ndarray:
75
+ """Return the oriented line kernels of a configuration, shape ``(num_kernels, size, size)``.
76
+
77
+ Built once per configuration and cached, so the same array is shared by every call; it is
78
+ returned read-only so no caller can change the kernels of later calls.
79
+
80
+ Parameters
81
+ ----------
82
+ num_kernels
83
+ Number of evenly spaced orientations.
84
+ kernel_size
85
+ Kernel spatial size in pixels.
86
+ sigma1
87
+ Inner Gaussian sigma in pixels.
88
+ sigma_delta
89
+ Outer minus inner Gaussian sigma, in pixels.
90
+
91
+ Returns
92
+ -------
93
+ np.ndarray
94
+ Read-only array of balanced difference-of-Gaussians kernels.
95
+ """
96
+ angles = np.radians(line_orientations(num_kernels))
97
+ cos = np.cos(angles)[:, None, None]
98
+ sin = np.sin(angles)[:, None, None]
99
+ cols, rows = _kernel_cells(kernel_size)
100
+ signed = cos * cols[None] - sin * rows[None] # (num_kernels, size, size)
101
+
102
+ s = float(kernel_size)
103
+ sigma_a = sigma1 / s
104
+ sigma_b = (sigma1 + sigma_delta) / s
105
+ norm_a = 1.0 / (sigma_a * np.sqrt(2.0 * np.pi))
106
+ norm_b = 1.0 / (sigma_b * np.sqrt(2.0 * np.pi))
107
+ gaussian_a = norm_a * np.exp(-((signed / sigma_a) ** 2) / 2.0)
108
+ gaussian_b = norm_b * np.exp(-((signed / sigma_b) ** 2) / 2.0)
109
+ diff = gaussian_a - gaussian_b
110
+ kernels = np.stack([_balance(d) for d in diff])
111
+ kernels.setflags(write=False)
112
+ return kernels
113
+
114
+
115
+ def detect_line_pixels(
116
+ mask: np.ndarray,
117
+ num_kernels: int,
118
+ kernel_size: int,
119
+ sigma1: float,
120
+ sigma_delta: float,
121
+ threshold: float,
122
+ ) -> np.ndarray:
123
+ """Detect linear features by convolving with oriented difference-of-Gaussians kernels.
124
+
125
+ For each orientation the mask is convolved with the oriented kernel; a pixel is detected
126
+ when, for at least one orientation, the response and the mask both exceed ``threshold`` and
127
+ the response is finite. The result is the union over orientations.
128
+
129
+ Parameters
130
+ ----------
131
+ mask
132
+ 2D array ``(H, W)``, values in ``{0, 1}`` or float ``[0, 1]``.
133
+ num_kernels
134
+ Number of oriented kernels.
135
+ kernel_size
136
+ Kernel spatial size in pixels (odd).
137
+ sigma1
138
+ Inner Gaussian sigma in pixels.
139
+ sigma_delta
140
+ Outer minus inner Gaussian sigma, in pixels.
141
+ threshold
142
+ Response / mask value threshold for detection.
143
+
144
+ Returns
145
+ -------
146
+ np.ndarray
147
+ Boolean ``(H, W)`` mask of detected pixels.
148
+ """
149
+ if mask.size == 0:
150
+ return np.zeros(mask.shape, dtype=bool)
151
+
152
+ kernels = build_kernels(num_kernels, kernel_size, sigma1, sigma_delta)
153
+ above = mask > threshold
154
+ detected = np.zeros(mask.shape, dtype=bool)
155
+ for kernel in kernels:
156
+ response = oaconvolve(mask, kernel, mode="same")
157
+ detected |= (response > threshold) & above & np.isfinite(response)
158
+ return detected
@@ -0,0 +1,77 @@
1
+ """Pixel-set overlap and greedy duplicate removal of contrail candidates.
2
+
3
+ Private module (leading underscore); not exported from ``deepcontrail.instance``.
4
+
5
+ Two regions overlap when they share pixels (contrails crossing). A candidate is a duplicate
6
+ of an already-kept one when their pixel-set IoU exceeds a threshold; the first found is kept.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+
14
+ def region_iou(
15
+ index_a: np.ndarray,
16
+ index_b: np.ndarray,
17
+ ) -> float:
18
+ """Pixel-set IoU of two regions given as arrays of unique flat pixel indices.
19
+
20
+ Parameters
21
+ ----------
22
+ index_a
23
+ Flat pixel indices (``row * width + col``) of the first region, unique.
24
+ index_b
25
+ Flat pixel indices of the second region, unique.
26
+
27
+ Returns
28
+ -------
29
+ float
30
+ Intersection-over-union in ``[0, 1]``; ``0.0`` when the union is empty.
31
+ """
32
+ shared = np.intersect1d(index_a, index_b).size
33
+ if shared == 0:
34
+ return 0.0
35
+ union = int(index_a.size) + int(index_b.size) - int(shared)
36
+ return shared / union
37
+
38
+
39
+ def _flat_indices(candidates: list[tuple[np.ndarray, np.ndarray]]) -> list[np.ndarray]:
40
+ """One array of unique flat pixel indices per candidate, under a shared image width."""
41
+ if not candidates:
42
+ return []
43
+ width = max(int(cols.max()) for _, cols in candidates) + 1
44
+ return [(rows * width + cols).astype(np.int64) for rows, cols in candidates]
45
+
46
+
47
+ def remove_duplicates(
48
+ candidates: list[tuple[np.ndarray, np.ndarray]],
49
+ iou_threshold: float,
50
+ ) -> list[tuple[np.ndarray, np.ndarray]]:
51
+ """Drop candidates that overlap an already-kept one beyond ``iou_threshold``.
52
+
53
+ Walks the candidates in order; a candidate is kept only if its IoU with every candidate
54
+ kept so far is at most ``iou_threshold``. The surviving candidates preserve the input
55
+ order and references.
56
+
57
+ Parameters
58
+ ----------
59
+ candidates
60
+ Candidate contrails as ``(rows, cols)`` coordinate arrays.
61
+ iou_threshold
62
+ IoU above which two candidates are considered duplicates.
63
+
64
+ Returns
65
+ -------
66
+ list[tuple[np.ndarray, np.ndarray]]
67
+ The surviving candidates, in the original order.
68
+ """
69
+ indices = _flat_indices(candidates)
70
+ kept: list[tuple[np.ndarray, np.ndarray]] = []
71
+ kept_indices: list[np.ndarray] = []
72
+ for candidate, index in zip(candidates, indices, strict=True):
73
+ duplicate = any(region_iou(index, other) > iou_threshold for other in kept_indices)
74
+ if not duplicate:
75
+ kept.append(candidate)
76
+ kept_indices.append(index)
77
+ return kept
@@ -0,0 +1,190 @@
1
+ """The shape tests a growing region must pass: straightness, length and quality score.
2
+
3
+ Private module (leading underscore); not exported from ``deepcontrail.instance``.
4
+
5
+ The direction-independent straightness and the length test follow the Mannstein-style detection of
6
+ Mannstein, Meyer & Wendling (1999), "Operational detection of contrails from NOAA-AVHRR
7
+ data", Int. J. Remote Sensing 20(8), 1641-1660, doi:10.1080/014311699212650: a candidate
8
+ region is kept only when it is both straight and long.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+
16
+ def _dominant_fit(
17
+ sum_cross: int, sum_dep: int, sum_indep: int, sum_indep_sq: int, n: int
18
+ ) -> tuple[float, float]:
19
+ """Least-squares slope and intercept of ``dep`` on ``indep`` from exact integer sums.
20
+
21
+ The sums are over the whole region: ``sum_cross`` is Σ(dep·indep), ``sum_dep`` is Σ(dep),
22
+ ``sum_indep`` is Σ(indep) and ``sum_indep_sq`` is Σ(indep²). With no spread in the
23
+ independent variable the slope is ``0.0`` and the intercept is the mean of the dependent
24
+ variable.
25
+
26
+ Parameters
27
+ ----------
28
+ sum_cross
29
+ Sum of the products of the dependent and independent coordinates.
30
+ sum_dep
31
+ Sum of the dependent coordinates.
32
+ sum_indep
33
+ Sum of the independent coordinates.
34
+ sum_indep_sq
35
+ Sum of the squares of the independent coordinates.
36
+ n
37
+ Number of coordinates summed.
38
+
39
+ Returns
40
+ -------
41
+ tuple[float, float]
42
+ ``(slope, intercept)``.
43
+ """
44
+ denom = n * sum_indep_sq - sum_indep * sum_indep
45
+ if denom == 0:
46
+ return 0.0, sum_dep / n
47
+ slope = (n * sum_cross - sum_indep * sum_dep) / denom
48
+ return slope, (sum_dep - slope * sum_indep) / n
49
+
50
+
51
+ def straightness(rows: np.ndarray, cols: np.ndarray) -> float:
52
+ """How straight a set of pixels is, whatever its direction: ((λ1 - λ2) / (λ1 + λ2))².
53
+
54
+ λ1 ≥ λ2 are the eigenvalues of the 2x2 covariance of the pixel coordinates. A line scores 1
55
+ and a round blob 0. For pixels along the 45° diagonal this is the least-squares R² of rows
56
+ against columns; it is also the largest such R² over all rotations of the pixels, so unlike
57
+ that R² it does not drop to 0 for a straight contrail along an image axis.
58
+
59
+ Parameters
60
+ ----------
61
+ rows
62
+ Row coordinates of the pixels.
63
+ cols
64
+ Column coordinates of the pixels.
65
+
66
+ Returns
67
+ -------
68
+ float
69
+ Straightness in ``[0, 1]``; ``0.0`` when all pixels coincide.
70
+ """
71
+ coords = np.stack([np.asarray(rows, dtype=float), np.asarray(cols, dtype=float)])
72
+ coords -= coords.mean(axis=1, keepdims=True)
73
+ var_r, var_c = float((coords[0] ** 2).mean()), float((coords[1] ** 2).mean())
74
+ cov = float((coords[0] * coords[1]).mean())
75
+ total = var_r + var_c # λ1 + λ2
76
+ if total == 0.0:
77
+ return 0.0
78
+ gap = float(np.hypot(var_r - var_c, 2.0 * cov)) # λ1 - λ2
79
+ return min((gap / total) ** 2, 1.0)
80
+
81
+
82
+ def is_long_and_straight(
83
+ rows: np.ndarray,
84
+ cols: np.ndarray,
85
+ min_r_score: float,
86
+ min_length: float,
87
+ avg_size: int,
88
+ ) -> bool:
89
+ """Keep a region only if it is straight enough and long enough.
90
+
91
+ Compares the region's :func:`straightness` with ``min_r_score²`` (so ``min_r_score`` reads
92
+ like a correlation threshold). The length runs between the means of the ``avg_size`` extreme
93
+ coordinates on each axis, projected onto a line fitted along the more-spread axis from the
94
+ exact integer sums of the whole region, and is compared with ``min_length``.
95
+
96
+ Parameters
97
+ ----------
98
+ rows
99
+ Row coordinates of the region pixels.
100
+ cols
101
+ Column coordinates of the region pixels.
102
+ min_r_score
103
+ Straightness threshold, squared before the comparison.
104
+ min_length
105
+ Minimum contrail pixel length.
106
+ avg_size
107
+ Number of extreme endpoints averaged per axis.
108
+
109
+ Returns
110
+ -------
111
+ bool
112
+ True if the region is both linear and long enough.
113
+ """
114
+ rows_arr = np.asarray(rows, dtype=float)
115
+ cols_arr = np.asarray(cols, dtype=float)
116
+ n = len(rows_arr)
117
+
118
+ if n < avg_size * 2:
119
+ return False
120
+
121
+ # Endpoint means: the avg_size smallest and largest coordinates on each axis. The
122
+ # coordinates are integers, so partitioning for the extremes and averaging gives the same
123
+ # values as a full sort would.
124
+ r_lo, r_hi = _axis_endpoints(rows_arr, n, avg_size)
125
+ c_lo, c_hi = _axis_endpoints(cols_arr, n, avg_size)
126
+
127
+ # Exact integer sums of the whole region for the dominant-axis fit.
128
+ r_int = rows_arr.astype(np.int64)
129
+ c_int = cols_arr.astype(np.int64)
130
+ sum_r = int(r_int.sum())
131
+ sum_c = int(c_int.sum())
132
+ sum_r2 = int((r_int * r_int).sum())
133
+ sum_c2 = int((c_int * c_int).sum())
134
+ sum_rc = int((r_int * c_int).sum())
135
+
136
+ if abs(c_lo - c_hi) > abs(r_lo - r_hi):
137
+ # Columns spread more: predict rows from columns.
138
+ slope, intercept = _dominant_fit(sum_rc, sum_r, sum_c, sum_c2, n)
139
+ r_lo, r_hi = c_lo * slope + intercept, c_hi * slope + intercept
140
+ else:
141
+ # Rows spread more: predict columns from rows.
142
+ slope, intercept = _dominant_fit(sum_rc, sum_c, sum_r, sum_r2, n)
143
+ c_lo, c_hi = r_lo * slope + intercept, r_hi * slope + intercept
144
+
145
+ length = float(np.hypot(c_lo - c_hi, r_lo - r_hi))
146
+ linearity = straightness(rows_arr, cols_arr)
147
+ return bool(linearity > np.square(min_r_score) and length > min_length)
148
+
149
+
150
+ def _axis_endpoints(values: np.ndarray, n: int, avg_size: int) -> tuple[float, float]:
151
+ """Means of the ``avg_size`` smallest and largest coordinates along one axis."""
152
+ lo = np.partition(values, avg_size - 1)[:avg_size]
153
+ hi = np.partition(values, n - avg_size)[n - avg_size :]
154
+ return float(lo.mean()), float(hi.mean())
155
+
156
+
157
+ def candidate_score(
158
+ rows: np.ndarray,
159
+ cols: np.ndarray,
160
+ saturation: float,
161
+ avg_size: int,
162
+ ) -> float:
163
+ """Quality score of a contrail: straightness * tanh(n_pixels / saturation).
164
+
165
+ Returns ``0.0`` when the blob has fewer than ``2 * avg_size`` pixels.
166
+
167
+ Parameters
168
+ ----------
169
+ rows
170
+ Row coordinates of the blob pixels.
171
+ cols
172
+ Column coordinates of the blob pixels.
173
+ saturation
174
+ Pixel count at which the size term approaches 1.
175
+ avg_size
176
+ Number of end pixels averaged when measuring the length (sets the minimum size).
177
+
178
+ Returns
179
+ -------
180
+ float
181
+ Quality score in ``[0, 1]``.
182
+ """
183
+ rows_arr = np.asarray(rows)
184
+ cols_arr = np.asarray(cols)
185
+
186
+ if len(rows_arr) < 2 * avg_size:
187
+ return 0.0
188
+
189
+ n_pixels = float(len(rows_arr))
190
+ return float(straightness(rows_arr, cols_arr) * np.tanh(n_pixels / saturation))
@@ -0,0 +1,97 @@
1
+ """Search spaces of the instance tuning scripts (Optuna trials).
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
+ Each ``suggest_*`` function builds a config from a trial; each ``*_trial_params`` function turns
7
+ a config back into the trial parameters, so known configs can be enqueued as starting trials.
8
+ Optuna is only needed by the caller: nothing here imports it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any, Literal, cast
14
+
15
+ from deepcontrail.instance.config import InstancePipelineConfig, MannsteinConfig
16
+
17
+ if TYPE_CHECKING:
18
+ from optuna.trial import BaseTrial
19
+
20
+
21
+ def suggest_mannstein_config(trial: BaseTrial) -> MannsteinConfig:
22
+ """Build a ``MannsteinConfig`` from a trial; the line filter stays off.
23
+
24
+ The line-filter settings are not searched: the filter is off in every search, as it kept
25
+ every mask pixel at the tuned settings. ``filter_avg_size`` is searched separately from
26
+ ``fit_avg_size``, so the length filter and the score can average a different number of end
27
+ pixels. Parameters are suggested in a fixed order, which determines what a seeded sampler
28
+ draws.
29
+ """
30
+ return MannsteinConfig(
31
+ line_filter=False,
32
+ min_pixels=trial.suggest_int("min_pixels", 3, 50),
33
+ min_length=trial.suggest_float("min_length", 3.0, 50.0),
34
+ min_r_score=trial.suggest_float("min_r_score", 0.05, 0.6),
35
+ lookback=trial.suggest_int("lookback", 10, 60),
36
+ alignment_threshold=trial.suggest_float("alignment_threshold", 0.5, 5.0),
37
+ fit_avg_size=trial.suggest_int("fit_avg_size", 1, 10),
38
+ iou_threshold=trial.suggest_float("iou_threshold", 0.2, 0.8),
39
+ score_saturation=trial.suggest_float("score_saturation", 10.0, 100.0),
40
+ filter_avg_size=trial.suggest_int("filter_avg_size", 1, 10),
41
+ )
42
+
43
+
44
+ _SEARCHED = (
45
+ "min_pixels",
46
+ "min_length",
47
+ "min_r_score",
48
+ "lookback",
49
+ "alignment_threshold",
50
+ "fit_avg_size",
51
+ "iou_threshold",
52
+ "score_saturation",
53
+ "filter_avg_size",
54
+ )
55
+
56
+
57
+ def mannstein_trial_params(config: MannsteinConfig) -> dict[str, Any]:
58
+ """Return the searched settings of a config as trial parameters.
59
+
60
+ ``filter_avg_size=None`` becomes ``fit_avg_size``; the line-filter settings are dropped.
61
+ """
62
+ params = {name: getattr(config, name) for name in _SEARCHED}
63
+ if params["filter_avg_size"] is None:
64
+ params["filter_avg_size"] = params["fit_avg_size"]
65
+ return params
66
+
67
+
68
+ def suggest_pipeline_config(trial: BaseTrial) -> InstancePipelineConfig:
69
+ """Build an ``InstancePipelineConfig`` from a trial; scores are always probability-weighted.
70
+
71
+ The Mannstein part is :func:`suggest_mannstein_config`, suggested first.
72
+ """
73
+ mannstein = suggest_mannstein_config(trial)
74
+ return InstancePipelineConfig(
75
+ semantic_threshold=trial.suggest_float("semantic_threshold", 0.3, 0.7),
76
+ grow_threshold=trial.suggest_float("grow_threshold", 0.2, 0.6),
77
+ grow_steps=trial.suggest_int("grow_steps", 0, 3),
78
+ grow_connectivity=cast(
79
+ "Literal[4, 8]", trial.suggest_categorical("grow_connectivity", [4, 8])
80
+ ),
81
+ probability_scores=True,
82
+ mannstein=mannstein,
83
+ )
84
+
85
+
86
+ def pipeline_trial_params(config: InstancePipelineConfig) -> dict[str, Any]:
87
+ """Flatten a pipeline config into the trial parameter names of the pipeline search."""
88
+ params = mannstein_trial_params(config.mannstein)
89
+ params.update(
90
+ {
91
+ "semantic_threshold": config.semantic_threshold,
92
+ "grow_threshold": config.grow_threshold,
93
+ "grow_steps": config.grow_steps,
94
+ "grow_connectivity": config.grow_connectivity,
95
+ }
96
+ )
97
+ return params
@@ -0,0 +1,116 @@
1
+ """The instance step stored in a pretrained semantic bundle.
2
+
3
+ A pretrained bundle (``deepcontrail.segmentation.load_pretrained``) may carry the instance step
4
+ tuned for its model, as two optional files next to the weights:
5
+
6
+ - ``instance_pipeline.yaml``: the :class:`InstancePipelineConfig` to run on its probabilities;
7
+ - ``score_model.json``: a :class:`ScoreModel` trained on that model's predictions.
8
+
9
+ This module reads and writes them from a folder only, so ``deepcontrail.instance`` never imports
10
+ ``deepcontrail.segmentation``::
11
+
12
+ model = load_pretrained("eurocontrol-asu/deepcontrail-segmentation")
13
+ step = load_instance_step(model.directory, tta=True)
14
+ masks, scores = step(predict(model, composite, tta=True))
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING
22
+
23
+ from deepcontrail.instance.config import InstancePipelineConfig
24
+ from deepcontrail.instance.pipeline import detect_instances_from_probabilities
25
+ from deepcontrail.instance.scoring import ScoreModel
26
+
27
+ if TYPE_CHECKING:
28
+ import numpy as np
29
+
30
+ PIPELINE_FILE = "instance_pipeline.yaml"
31
+ SCORE_MODEL_FILE = "score_model.json"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class InstanceStep:
36
+ """Pipeline settings and optional learned scores; call it on a probability map."""
37
+
38
+ config: InstancePipelineConfig
39
+ score_model: ScoreModel | None = None
40
+
41
+ def __call__(self, probs: np.ndarray) -> tuple[list[np.ndarray], list[float]]:
42
+ """Run :func:`detect_instances_from_probabilities` with this step's settings."""
43
+ return detect_instances_from_probabilities(probs, self.config, self.score_model)
44
+
45
+
46
+ def load_instance_step(directory: str | Path, *, tta: bool | None = None) -> InstanceStep:
47
+ """Read the instance step of a bundle folder; defaults where its files are absent.
48
+
49
+ Parameters
50
+ ----------
51
+ directory
52
+ The bundle folder, e.g. ``Pretrained.directory``.
53
+ tta
54
+ Whether the probabilities will be made with test-time augmentation. A score model that
55
+ records the other setting is refused: TTA changes the probabilities its features are
56
+ computed from. ``None`` skips the check.
57
+
58
+ Returns
59
+ -------
60
+ InstanceStep
61
+ ``instance_pipeline.yaml`` (else ``InstancePipelineConfig()``) and ``score_model.json``
62
+ (else ``None``).
63
+
64
+ Raises
65
+ ------
66
+ FileNotFoundError
67
+ If ``directory`` does not exist (a typo would otherwise silently drop the learned scores).
68
+ ValueError
69
+ If the score model was trained with other pipeline settings than the bundle's, or with
70
+ the other TTA setting.
71
+ """
72
+ directory = Path(directory)
73
+ if not directory.is_dir():
74
+ raise FileNotFoundError(f"{directory}: no such bundle folder")
75
+ pipeline_path = directory / PIPELINE_FILE
76
+ config = (
77
+ InstancePipelineConfig.from_yaml(pipeline_path)
78
+ if pipeline_path.is_file()
79
+ else InstancePipelineConfig()
80
+ )
81
+ score_path = directory / SCORE_MODEL_FILE
82
+ if not score_path.is_file():
83
+ return InstanceStep(config)
84
+ score_model = ScoreModel.from_json(score_path)
85
+ if score_model.pipeline_config != config:
86
+ raise ValueError(
87
+ f"{score_path} was trained with another pipeline config than {pipeline_path.name}"
88
+ )
89
+ trained_tta = score_model.trained_on.get("tta")
90
+ if tta is not None and trained_tta is not None and trained_tta != tta:
91
+ raise ValueError(
92
+ f"{score_path} was trained on probabilities made with TTA={trained_tta}, "
93
+ f"but they will be made with TTA={tta}"
94
+ )
95
+ return InstanceStep(config, score_model)
96
+
97
+
98
+ def save_instance_step(
99
+ directory: str | Path,
100
+ config: InstancePipelineConfig,
101
+ score_model: ScoreModel | None = None,
102
+ ) -> None:
103
+ """Write ``instance_pipeline.yaml`` (and ``score_model.json``) into a bundle folder.
104
+
105
+ Raises
106
+ ------
107
+ ValueError
108
+ If ``score_model`` was trained with other pipeline settings than ``config``.
109
+ """
110
+ if score_model is not None and score_model.pipeline_config != config:
111
+ raise ValueError("the score model was trained with another pipeline config")
112
+ directory = Path(directory)
113
+ directory.mkdir(parents=True, exist_ok=True)
114
+ config.to_yaml(directory / PIPELINE_FILE)
115
+ if score_model is not None:
116
+ score_model.to_json(directory / SCORE_MODEL_FILE)