dpolcat 0.2.0.dev1__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.
dpolcat/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .core import * # noqa: I001
2
+ from . import utils, processor, modelling # noqa: F401
dpolcat/core.py ADDED
@@ -0,0 +1,144 @@
1
+ """
2
+ dpolcat/core - polarimetric categorization of dual-pol. synthetic aperture radar (SAR) data
3
+
4
+ Author: Luke McQuade
5
+ """
6
+
7
+ from numba import njit
8
+ import numpy as np
9
+ import xarray
10
+
11
+
12
+ def linear_to_db(x):
13
+ """Convert linear backscatter to power ratio (decibels)."""
14
+ return 10 * np.log10(x)
15
+
16
+
17
+ # This used to do a different transform, keep this as a passthrough until the rest of the code is updated.
18
+ def scale_nice(x):
19
+ return linear_to_db(x)
20
+
21
+
22
+ def xr_scale_nice(x):
23
+ """XArray, Dask-oriented, version of scaling function."""
24
+ return xarray.apply_ufunc(scale_nice, x, dask="parallelized", output_dtypes=[float])
25
+
26
+
27
+ # Remember to keep this updated.
28
+ NUM_CATEGORIES = 20
29
+ """Total number of categories."""
30
+
31
+
32
+ @njit
33
+ def categorize(vv_scaled, vh_scaled):
34
+ """
35
+ Our dual-polarimetric categorization decision tree algorithm.
36
+ Inputs should be in numpy or xarray-compatible form, scaled using our custom scaling function.
37
+ """
38
+ vv = vv_scaled
39
+ vh = vh_scaled
40
+
41
+ i = 0
42
+ if vv is None or vh is None:
43
+ # NoData/invalid
44
+ i = 0
45
+ # Low volume scatter, ie., predominantly surface scatterers
46
+ elif -np.inf <= vh < -20.0:
47
+ if -np.inf <= vv < -18.0:
48
+ i = 1
49
+ elif -18.0 <= vv < -16.0:
50
+ i = 2
51
+ elif -16.0 <= vv < -14.0:
52
+ i = 3
53
+ elif -14.0 <= vv < -6.0:
54
+ i = 4
55
+ elif -6.0 <= vv < 2.0:
56
+ i = 5
57
+ elif vv >= 2.0:
58
+ # Higher values, likely to be double-bounce (buildings) or terrain effects
59
+ i = 13 # [merged A]
60
+ elif vv < -18.0:
61
+ if vh < -14.0:
62
+ # Low specular with some cross-pol, e.g., moist soil
63
+ i = 6
64
+ elif vh >= -14.0:
65
+ # Predominantly polarizing surfaces (unrealistic)
66
+ i = 19 # [merged C]
67
+ # Combinations of scatter, e.g, natural volume scatterers
68
+ elif -18.0 <= vv < -14.0 and -22.0 <= vh < -14.0:
69
+ i = 7
70
+ elif -14.0 <= vv < -2.0 and -14.0 <= vh < -10.0:
71
+ i = 12
72
+
73
+ # Further mid-range categories
74
+ elif -18.0 <= vv < -14.0 and -14.0 <= vh < -10.0:
75
+ i = 19 # [merged C]
76
+ elif -18.0 <= vv < -6.0 and -10.0 <= vh < 2.0:
77
+ # Maybe double-bounce
78
+ i = 14
79
+ elif -14.0 <= vv < -6.0 and -22.0 <= vh < -17.0:
80
+ i = 8
81
+ elif -14.0 <= vv < -6.0 and -17.0 <= vh < -15.6:
82
+ i = 9
83
+ elif -14.0 <= vv < -6.0 and -15.6 <= vh < -14.0:
84
+ i = 10
85
+ elif -6.0 <= vv < 2.0 and -22.0 <= vh < -14.0:
86
+ i = 11
87
+ elif -2.0 <= vv < 2.0 and -14.0 <= vh < -10.0:
88
+ i = 15 # [merged B] #
89
+
90
+ # Higher values, likely to be double-bounce (buildings) or terrain effects.
91
+ elif -6.0 <= vv < 2.0 and -10.0 <= vh < 2.0:
92
+ i = 15 # [merged B]
93
+ elif vv >= 2.0 and -22.0 <= vh < -10.0:
94
+ i = 13 # [merged A]
95
+ elif vv >= 2.0 and -10.0 <= vh < 2.0:
96
+ i = 16
97
+ elif vh >= 2.0 and -22.0 < vv < 2.0:
98
+ i = 17
99
+ elif vv >= 2.0 and vh >= 2.0:
100
+ i = 18
101
+
102
+ return i
103
+
104
+
105
+ categorize_np = np.vectorize(categorize, otypes=[np.uint8])
106
+ """Numpy vectorized version of the categorizer."""
107
+
108
+
109
+ def xr_categorize(vv_scaled, vh_scaled):
110
+ """XArray, Dask-oriented, version of the categorizer."""
111
+ return xarray.apply_ufunc(
112
+ categorize_np,
113
+ vv_scaled,
114
+ vh_scaled,
115
+ dask="parallelized",
116
+ output_dtypes=[np.uint8],
117
+ ).rename("dpolcat")
118
+
119
+
120
+ color_list = np.array(
121
+ [
122
+ [0, 0, 0], # 0
123
+ [129, 244, 255], # 1
124
+ [199, 180, 215], # 2
125
+ [166, 121, 215], # 3
126
+ [255, 200, 128], # 4
127
+ [203, 73, 114], # 5
128
+ [222, 123, 214], # 6
129
+ [251, 210, 212], # 7
130
+ [231, 251, 187], # 8
131
+ [200, 187, 66], # 9
132
+ [157, 225, 90], # 10
133
+ [114, 73, 114], # 11
134
+ [105, 69, 42], # 12
135
+ [200, 10, 30], # 13
136
+ [255, 255, 0], # 14
137
+ [255, 204, 0], # 15
138
+ [158, 34, 108], # 16
139
+ [217, 0, 255], # 17
140
+ [252, 74, 0], # 18
141
+ [70, 70, 70], # 19
142
+ ]
143
+ )
144
+ """Suggested category colours, of the form [R,G,B (0 to 255)]"""
dpolcat/fast_frost.py ADDED
@@ -0,0 +1,184 @@
1
+ """
2
+ Fast Frost speckle filters using Numba.
3
+
4
+ frost_filter_fast does minimal numerical checking. It will break (silently) if there are *any* NaNs in the input.
5
+
6
+ frost_filter_nanny is NaN-aware, but this comes with a performance penalty.
7
+
8
+ Based on findpeaks.filters [0] / PyRadar [1], and the original paper [2].
9
+
10
+ Mostly generated with ChatGPT o3 (17-Jun-2025).
11
+
12
+
13
+ [0]: https://github.com/erdogant/findpeaks/blob/master/findpeaks/filters/frost.py
14
+
15
+ [1]: https://github.com/PyRadar/pyradar
16
+
17
+ [2]: Frost, Victor S., Josephine Abbott Stiles, K. S. Shanmugan, and Julian C. Holtzman. 'A Model for Radar Images and Its Application to Adaptive Digital Filtering of Multiplicative Noise'. IEEE Transactions on Pattern Analysis and Machine Intelligence PAMI-4, no. 2 (March 1982): 15766. https://doi.org/10.1109/TPAMI.1982.4767223.
18
+
19
+ Licence: LGPL-3.0.
20
+
21
+ """
22
+
23
+ import numpy as np
24
+ from scipy.ndimage import uniform_filter
25
+ from numba import njit, prange
26
+
27
+
28
+ @njit(parallel=True, fastmath=True)
29
+ def _frost_core(img, factor_A, r):
30
+ """Inner loop: compute weighted average for every pixel."""
31
+ n_rows, n_cols = img.shape
32
+ out = np.empty_like(img)
33
+
34
+ for i in prange(n_rows): # parallel loop
35
+ for j in range(n_cols):
36
+ # window bounds (clamped to image)
37
+ x0 = max(i - r, 0); x1 = min(i + r + 1, n_rows)
38
+ y0 = max(j - r, 0); y1 = min(j + r + 1, n_cols)
39
+
40
+ centre = img[i, j]
41
+ A = factor_A[i, j]
42
+
43
+ w_sum = 0.0
44
+ v_sum = 0.0
45
+ for x in range(x0, x1):
46
+ for y in range(y0, y1):
47
+ diff = abs(img[x, y] - centre)
48
+ w = np.exp(-A * diff)
49
+ w_sum += w
50
+ v_sum += w * img[x, y]
51
+
52
+ out[i, j] = v_sum / w_sum if w_sum > 0.0 else 0.0
53
+ return out
54
+
55
+
56
+ def frost_filter_fast(img, *, damping_factor: float = 2.0,
57
+ win_size: int = 3, dtype=np.float32):
58
+ """
59
+ Frost speckle filter accelerated with Numba.
60
+
61
+ Parameters
62
+ ----------
63
+ img : 2-D ndarray
64
+ damping_factor : float, default 2.0
65
+ win_size : odd int ≥ 3, default 3
66
+ dtype : numpy dtype, default float32
67
+
68
+ Returns
69
+ -------
70
+ ndarray (same shape as *img*)
71
+ """
72
+ if img.ndim != 2:
73
+ raise ValueError("Input must be a 2-D array.")
74
+ if win_size < 3 or win_size % 2 == 0:
75
+ raise ValueError("win_size must be odd and ≥ 3.")
76
+
77
+ img = np.asarray(img, dtype=dtype, order='C') # contiguous for Numba
78
+ r = win_size // 2
79
+
80
+ # Local mean and variance via fast box filter
81
+ mean = uniform_filter(img, size=win_size, mode='reflect')
82
+ mean_sq = uniform_filter(img * img, size=win_size, mode='reflect')
83
+ var = np.clip(mean_sq - mean * mean, 0, None)
84
+ std = np.sqrt(var)
85
+
86
+ # Frost parameters
87
+ sigma0 = std / np.maximum(mean, 1e-12) / np.maximum(mean, 1e-12)
88
+ factor_A = damping_factor * sigma0
89
+
90
+ return _frost_core(img, factor_A, r).astype(img.dtype, copy=False)
91
+
92
+
93
+ @njit(parallel=True, fastmath=True)
94
+ def _frost_core_nanny(img, factor_A, win_size):
95
+ """Numba kernel: weight computation + pixel update (ignores NaNs)."""
96
+ n_rows, n_cols = img.shape
97
+ r = win_size // 2
98
+ out = np.empty_like(img)
99
+
100
+ for i in prange(n_rows):
101
+ for j in range(n_cols):
102
+ centre = img[i, j]
103
+ if np.isnan(centre): # centre missing → output NaN
104
+ out[i, j] = np.nan
105
+ continue
106
+
107
+ x0 = max(i - r, 0)
108
+ x1 = min(i + r + 1, n_rows)
109
+ y0 = max(j - r, 0)
110
+ y1 = min(j + r + 1, n_cols)
111
+
112
+ A = factor_A[i, j]
113
+ w_sum = 0.0
114
+ v_sum = 0.0
115
+
116
+ for x in range(x0, x1):
117
+ for y in range(y0, y1):
118
+ pix = img[x, y]
119
+ if np.isnan(pix): # skip NaNs in the window
120
+ continue
121
+ diff = abs(pix - centre)
122
+ w = np.exp(-A * diff)
123
+ w_sum += w
124
+ v_sum += w * pix
125
+
126
+ out[i, j] = v_sum / w_sum if w_sum > 0 else np.nan
127
+ return out
128
+
129
+
130
+ def frost_filter_nanny(img, *, damping_factor: float = 2.0, win_size: int = 3,
131
+ dtype=np.float32):
132
+ """
133
+ Frost speckle filter accelerated with Numba (NaN-aware).
134
+
135
+ Parameters
136
+ ----------
137
+ img : 2-D ndarray
138
+ SAR intensity image (NaNs allowed).
139
+ damping_factor : float, default 2.0
140
+ Frost damping constant.
141
+ win_size : int, default 3 (must be odd ≥ 3)
142
+ Square window size.
143
+ dtype : numpy dtype, default np.float32
144
+ Internal working precision.
145
+
146
+ Returns
147
+ -------
148
+ filtered : ndarray
149
+ Frost-filtered image (same shape as *img*).
150
+ """
151
+ if img.ndim != 2:
152
+ raise ValueError("Input must be a 2-D array.")
153
+ if win_size < 3 or win_size % 2 == 0:
154
+ raise ValueError("win_size must be odd and ≥ 3.")
155
+
156
+ img = np.asarray(img, dtype=dtype, order='C') # C-contiguous
157
+
158
+ # ---- local mean & variance, ignoring NaNs ---- #
159
+ nan_mask = np.isfinite(img).astype(dtype) # 1 where valid, 0 where NaN
160
+ img_filled = np.where(nan_mask, img, 0)
161
+
162
+ # uniform_filter returns an *average* → multiply by win_size**2 to get sums
163
+ size = win_size
164
+ win_area = float(size * size)
165
+
166
+ sum_valid = uniform_filter(img_filled, size=size, mode='reflect') * win_area
167
+ sum_sq_valid = uniform_filter(img_filled * img_filled, size=size, mode='reflect') * win_area
168
+ count_valid = uniform_filter(nan_mask, size=size, mode='reflect') * win_area
169
+
170
+ # avoid division by zero where window has no valid pixels
171
+ mean = np.where(count_valid > 0, sum_valid / count_valid, np.nan)
172
+ var = np.where(count_valid > 0, sum_sq_valid / count_valid - mean * mean, np.nan)
173
+ std = np.sqrt(np.maximum(var, 0))
174
+
175
+ cv = std / mean # coefficient of variation
176
+ sigma_zero = cv / mean # std / mean²
177
+ factor_A = damping_factor * sigma_zero
178
+ factor_A = np.where(np.isfinite(factor_A), factor_A, 0)
179
+
180
+ # ---- JIT kernel ---- #
181
+ filtered = _frost_core_nanny(img, factor_A.astype(dtype), win_size)
182
+
183
+ return filtered
184
+
dpolcat/modelling.py ADDED
@@ -0,0 +1,124 @@
1
+ """
2
+ dpolcat/modelling - helper classes for semantic modelling
3
+
4
+ Author: Luke McQuade
5
+ """
6
+
7
+ from enum import IntEnum
8
+
9
+ import dpolcat
10
+
11
+ # Semantic API
12
+ # ------------
13
+ # Here we aim to present the polarimetric categories in a way
14
+ # that they can be used to create understandable semantic models.
15
+
16
+
17
+ class Surface(IntEnum):
18
+ """
19
+ Predominantly surface-scattering mechanics.
20
+ - `Smooth` (1): Very smooth surface scattering.
21
+ - `SlightlyRough` (2): Slightly rough surface scattering.
22
+ - `MediumRough` (3): Medium-rough surface scattering.
23
+ - `Rough` (4): Rough surface scattering.
24
+ - `VeryRough` (5): Very rough surface scattering.
25
+ """
26
+
27
+ Smooth = 1
28
+ """Very smooth surface scattering."""
29
+
30
+ SlightlyRough = 2
31
+ """Slightly rough surface scattering."""
32
+
33
+ MediumRough = 3
34
+ """Medium-rough surface scattering."""
35
+
36
+ Rough = 4
37
+ """Rough surface scattering."""
38
+
39
+ VeryRough = 5
40
+ """Very rough surface scattering."""
41
+
42
+
43
+ class Volume(IntEnum):
44
+ """
45
+ Volume-scattering mechanics.
46
+ - `LowCo` (6): Volume scatterer with low co-polar response.
47
+ - `MediumCo` (7): Volume scatterer with medium co-polar response.
48
+ - `Low` (8): Weakly volume-scattering object.
49
+ - `MediumLow` (9): Medium-weakly volume-scattering object.
50
+ - `Medium` (10): Medium-strength volume-scattering object.
51
+ - `HighCo` (11): Volume scatterer with high co-polar response.
52
+ - `High` (12): Highly volume-scattering object.
53
+ """
54
+
55
+ LowCo = 6
56
+ """Volume scatterer with low co-polar response."""
57
+
58
+ MediumCo = 7
59
+ """Volume scatterer with medium co-polar response."""
60
+
61
+ Low = 8
62
+ """Weakly volume-scattering object."""
63
+
64
+ MediumLow = 9
65
+ """Medium-weakly volume scattering object."""
66
+
67
+ Medium = 10
68
+ """Medium-strength volume scattering object."""
69
+
70
+ HighCo = 11
71
+ """Volume scatterer with high co-polar response."""
72
+
73
+ High = 12
74
+ """Highly volume-scattering object."""
75
+
76
+
77
+ class MultiBounce(IntEnum):
78
+ """
79
+ Double/triple-bounce mechanics.
80
+ - `HighCo` (13): High-strength co-polar double/triple-bounce, with low cross-polar response.
81
+ - `MedHighCross` (14): Medium-strength cross-polar double/triple-bounce, with low co-polar response.
82
+ - `MediumHigh` (15): Medium-high co- and cross-polar double/triple-bounce response.
83
+ - `HighMixedCo` (16): High-strength double/triple-bounce with a mostly co-polar response.
84
+ - `HighMixedCross` (17): High-strength double/triple-bounce with a mostly cross-polar response.
85
+ - `VeryHigh` (18): Very strong co- and cross-polar double/triple-bounce response.
86
+ """
87
+
88
+ HighCo = 13
89
+ """High strength co-polar double/triple-bounce, with low cross-polar, response."""
90
+
91
+ MedHighCross = 14
92
+ """Medium strength cross-polar double/triple-bounce, with low co-polar, response."""
93
+
94
+ MediumHigh = 15
95
+ """Medium-high co- and cross-polar double/triple-bounce response."""
96
+
97
+ HighMixedCo = 16
98
+ """High-strength double/triple-bounce with a mostly co-polar response."""
99
+
100
+ HighMixedCross = 17
101
+ """High-strength double/triple-bounce with a mostly cross-polar response."""
102
+
103
+ VeryHigh = 18
104
+ """Very strong co- and cross-polar double/triple-bounce response."""
105
+
106
+
107
+ class Other(IntEnum):
108
+ """
109
+ Other categories.
110
+ - `NoData` (0): No data.
111
+ - `Unknown` (19): Physically unrealistic/unknown mechanism.
112
+ """
113
+
114
+ NoData = 0
115
+ """No data."""
116
+
117
+ Unknown = 19
118
+ """Physically unrealistic/unknown mechanism."""
119
+
120
+
121
+ # A quick sanity check - do the definitions above line up with our expected number of categories?
122
+ assert set(range(dpolcat.NUM_CATEGORIES)) == set(Surface) | set(Volume) | set(
123
+ MultiBounce
124
+ ) | set(Other), "Category definitions do not match the expected number."
@@ -0,0 +1,31 @@
1
+ # Custom PlanetaryComputer RioDriver for use in odc.stac.load,
2
+ # which re-signs assets just before use (rather than up front).
3
+ # planetary_computer.sign has a token cache, so it's not overly expensive.
4
+ # Code via Claude Opus 5.5.
5
+
6
+ import dataclasses
7
+ from urllib.parse import urlsplit, urlunsplit
8
+
9
+ import planetary_computer
10
+ from odc.loader._rio import RioDriver, RioReader
11
+ from odc.stac._mdtools import StacAuxReader
12
+
13
+
14
+ def resign(url: str) -> str:
15
+ return planetary_computer.sign(urlunsplit(urlsplit(url)._replace(query="")))
16
+
17
+
18
+ class PCRioReader(RioReader):
19
+ def read(self, cfg, dst_geobox, *, dst=None, selection=None):
20
+ src = dataclasses.replace(self._src, uri=resign(self._src.uri))
21
+ return RioReader(src, self._ctx).read(
22
+ cfg, dst_geobox, dst=dst, selection=selection
23
+ )
24
+
25
+
26
+ class PCRioDriver(RioDriver):
27
+ def __init__(self):
28
+ super().__init__(aux_reader=StacAuxReader())
29
+
30
+ def open(self, src, ctx):
31
+ return PCRioReader(src, ctx)