deepcontrail-instance 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepcontrail/instance/__init__.py +49 -0
- deepcontrail/instance/_evaluation.py +392 -0
- deepcontrail/instance/_growing.py +236 -0
- deepcontrail/instance/_lines.py +158 -0
- deepcontrail/instance/_overlap.py +77 -0
- deepcontrail/instance/_shape.py +190 -0
- deepcontrail/instance/_tuning.py +97 -0
- deepcontrail/instance/bundle.py +116 -0
- deepcontrail/instance/config.py +237 -0
- deepcontrail/instance/io.py +139 -0
- deepcontrail/instance/labels.py +211 -0
- deepcontrail/instance/mannstein.py +114 -0
- deepcontrail/instance/metrics.py +232 -0
- deepcontrail/instance/pipeline.py +90 -0
- deepcontrail/instance/py.typed +0 -0
- deepcontrail/instance/refine.py +119 -0
- deepcontrail/instance/scoring.py +405 -0
- deepcontrail_instance-0.1.0.dist-info/METADATA +219 -0
- deepcontrail_instance-0.1.0.dist-info/RECORD +21 -0
- deepcontrail_instance-0.1.0.dist-info/WHEEL +4 -0
- deepcontrail_instance-0.1.0.dist-info/licenses/LICENSE +211 -0
|
@@ -0,0 +1,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)
|