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 +2 -0
- dpolcat/core.py +144 -0
- dpolcat/fast_frost.py +184 -0
- dpolcat/modelling.py +124 -0
- dpolcat/pc_rio_driver.py +31 -0
- dpolcat/processor.py +777 -0
- dpolcat/utils.py +412 -0
- dpolcat-0.2.0.dev1.dist-info/METADATA +202 -0
- dpolcat-0.2.0.dev1.dist-info/RECORD +11 -0
- dpolcat-0.2.0.dev1.dist-info/WHEEL +4 -0
- dpolcat-0.2.0.dev1.dist-info/licenses/LICENSE +201 -0
dpolcat/processor.py
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import marimo
|
|
2
|
+
|
|
3
|
+
__generated_with = "0.25.1"
|
|
4
|
+
app = marimo.App(width="medium")
|
|
5
|
+
|
|
6
|
+
with app.setup:
|
|
7
|
+
import marimo as mo
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from functools import partial
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import xml.etree.ElementTree as ET
|
|
14
|
+
|
|
15
|
+
import bokeh.models
|
|
16
|
+
import dask
|
|
17
|
+
from dask.diagnostics import ProgressBar
|
|
18
|
+
import geopandas as gpd
|
|
19
|
+
import numpy as np
|
|
20
|
+
import odc.stac
|
|
21
|
+
from osgeo import gdal
|
|
22
|
+
import pandas as pd
|
|
23
|
+
import pystac_client
|
|
24
|
+
import planetary_computer
|
|
25
|
+
import shapely.geometry as sg
|
|
26
|
+
import xarray
|
|
27
|
+
import holoviews as hv
|
|
28
|
+
import hvplot.pandas
|
|
29
|
+
import hvplot.xarray
|
|
30
|
+
import rioxarray
|
|
31
|
+
import zarr
|
|
32
|
+
|
|
33
|
+
from dpolcat.pc_rio_driver import PCRioDriver
|
|
34
|
+
import dpolcat as dp
|
|
35
|
+
import dpolcat.utils
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
gdal.UseExceptions()
|
|
39
|
+
|
|
40
|
+
hv.extension("bokeh")
|
|
41
|
+
|
|
42
|
+
# Increase output size to support heavy plots
|
|
43
|
+
# See https://github.com/marimo-team/marimo/issues/3376#issuecomment-3115395805
|
|
44
|
+
mo._runtime.context.get_context().marimo_config["runtime"][
|
|
45
|
+
"output_max_bytes"
|
|
46
|
+
] = 100000000
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.cell(hide_code=True)
|
|
50
|
+
def _():
|
|
51
|
+
mo.md(r"""
|
|
52
|
+
Luke McQuade, October 2026
|
|
53
|
+
|
|
54
|
+
# *dpolcat*: Processor
|
|
55
|
+
|
|
56
|
+
This is a notebook/module for creating Sentinel-1 *dpolcat* time series/datacubes for a user-supplied area of interest (AoI) and time period. The main steps of the pipeline:
|
|
57
|
+
- Find STAC items matching the AoI, time period, and selected orbit.
|
|
58
|
+
- Create a lazy Xarray dataset, grouped by datatake
|
|
59
|
+
- Apply speckle filtering (optional)
|
|
60
|
+
- Perform dpolcat categorization
|
|
61
|
+
|
|
62
|
+
This is written directly to a ZARR store. This can be appended to, for example, when new items are available. Lazy Dask processing is employed, meaning no data loading or processing is performed until the final writing step. I.e., between this the STAC search and this, the steps in the notebook just build up a Dask task graph.
|
|
63
|
+
|
|
64
|
+
Convention: This is a parameterized notebook intended to be run externally (see [marimo.App.run]( https://docs.marimo.io/api/app/#marimo.App.run)). Variables beginning with `p_` can be injected when running the notebook this way, e.g., `dpolcat.processor.app.run({"p_aoi": my_aoi, "p_date_range": my_date_range, ...})`. Be careful renaming/changing these! The most important are:
|
|
65
|
+
|
|
66
|
+
| Parameter | Description |
|
|
67
|
+
| - | - |
|
|
68
|
+
| `p_headless` | Whether to enable headless execution. Should be `True` when running externally. |
|
|
69
|
+
| `p_aoi` | The area of interest as a GeoDataFrame containing a single polygon. |
|
|
70
|
+
| `p_date_range` | The date range as a string with format `{from_date}/{to_date}`, e.g., "2020-01-01/2020-03-31" |
|
|
71
|
+
| `p_sat_direction` | Selected orbit direction ("ascending" or "descending") |
|
|
72
|
+
| `p_sat_rel_orbit` | Selected relative orbit number (integer) |
|
|
73
|
+
| `p_speckle_filter` | Speckle filter to apply, "Frost" (default) or "Unfiltered". |
|
|
74
|
+
| `p_speckle_filter_window` | Speckle filter window size, default 5. |
|
|
75
|
+
| `p_out_zarr_path` | Path to the ZARR store. |
|
|
76
|
+
| `p_write_intermediate_data` | Whether to write intermediate backscatter data also (defaults to False). |
|
|
77
|
+
""")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.cell
|
|
82
|
+
def _():
|
|
83
|
+
p_headless = False
|
|
84
|
+
return (p_headless,)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.cell
|
|
88
|
+
def _():
|
|
89
|
+
mo.md(r"""
|
|
90
|
+
## Search and Select Sentinel-1 RTC data
|
|
91
|
+
""")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.cell
|
|
96
|
+
def _():
|
|
97
|
+
# Sample AoI bounding boxes, with format:
|
|
98
|
+
# [lon_min, lat_min, lon_max, lat_max]
|
|
99
|
+
sample_aois = {
|
|
100
|
+
"SalzburgCity": [13.02, 47.76, 13.09, 47.83],
|
|
101
|
+
# Salzburg and Salzach Valley
|
|
102
|
+
"SalzburgSouth": [12.92, 47.50, 13.19, 47.86],
|
|
103
|
+
"Tenerife": [-16.96, 27.92, -16.05, 28.61],
|
|
104
|
+
"GranCanaria": [-15.84, 27.72, -15.34, 28.19],
|
|
105
|
+
# Eastern part of Lake Titicaca, La Paz, and some of the Cordillera Real
|
|
106
|
+
"Titicaca": [-69.41, -16.71, -67.87, -15.61],
|
|
107
|
+
"LaPaz": [-68.224, -16.603, -68.0542, -16.430],
|
|
108
|
+
# Inverness and surroundings to the East
|
|
109
|
+
"Inverness": [-4.32, 57.34, -3.63, 57.63],
|
|
110
|
+
"London": [-0.599, 51.231, 0.347, 51.734],
|
|
111
|
+
# Sentinel-2 footprint around Graz
|
|
112
|
+
"33TWN": [14.999, 46.856, 16.467, 47.853],
|
|
113
|
+
"Duisburg": [6.695, 51.407, 6.847, 51.462],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ui_aoi = mo.ui.dropdown(options=sample_aois.keys(), value=list(sample_aois.keys())[0])
|
|
117
|
+
ui_date_range = mo.ui.date_range(
|
|
118
|
+
start="2015-01-01", value=("2025-05-01", "2025-05-31")
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
mo.md(f"""
|
|
122
|
+
Area of interest: {ui_aoi} <br>
|
|
123
|
+
Search dates: {ui_date_range} <br>
|
|
124
|
+
""")
|
|
125
|
+
return sample_aois, ui_aoi, ui_date_range
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.cell
|
|
129
|
+
def _(ui_aoi):
|
|
130
|
+
p_aoi_name = ui_aoi.value
|
|
131
|
+
return (p_aoi_name,)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.cell
|
|
135
|
+
def _(p_aoi_name, sample_aois):
|
|
136
|
+
_bbox = sample_aois[p_aoi_name]
|
|
137
|
+
p_aoi = gpd.GeoDataFrame(geometry=[sg.box(*_bbox)], crs="EPSG:4326")
|
|
138
|
+
return (p_aoi,)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.cell
|
|
142
|
+
def _(p_aoi):
|
|
143
|
+
bbox = p_aoi.total_bounds
|
|
144
|
+
return (bbox,)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.cell
|
|
148
|
+
def _(ui_date_range):
|
|
149
|
+
_d1, _d2 = ui_date_range.value
|
|
150
|
+
p_date_range = f"{_d1.year}-{_d1.month:02}-{_d1.day:02}/{_d2.year}-{_d2.month:02}-{_d2.day:02}"
|
|
151
|
+
return (p_date_range,)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.cell(hide_code=True)
|
|
155
|
+
def _():
|
|
156
|
+
mo.md(r"""
|
|
157
|
+
Find best matching acquisitions - those which share the same orbit/track* and, when combined, cover the entirety of the AoI.
|
|
158
|
+
|
|
159
|
+
*This is crucial, as different acquisition geometries give incomparable results.
|
|
160
|
+
""")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.cell
|
|
165
|
+
def _(p_aoi, p_date_range):
|
|
166
|
+
stac_items_gdf = dpolcat.utils.find_stac_items(p_aoi, date_range=p_date_range)
|
|
167
|
+
df_coverages = dpolcat.utils.calc_coverages(stac_items_gdf, aoi=p_aoi)
|
|
168
|
+
return df_coverages, stac_items_gdf
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.cell
|
|
172
|
+
def _(df_coverages):
|
|
173
|
+
ui_orbit_selector = mo.ui.table(df_coverages.reset_index(), selection="single")
|
|
174
|
+
ui_orbit_selector
|
|
175
|
+
return (ui_orbit_selector,)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.cell
|
|
179
|
+
def _(ui_orbit_selector):
|
|
180
|
+
mo.stop(
|
|
181
|
+
len(ui_orbit_selector.value) == 0
|
|
182
|
+
or ui_orbit_selector.value["full_scenes"].values[0] == 0,
|
|
183
|
+
"Selected an entry with at least 1 full scene.",
|
|
184
|
+
)
|
|
185
|
+
p_sat_direction = ui_orbit_selector.value["sat:orbit_state"].values[0]
|
|
186
|
+
p_sat_rel_orbit = ui_orbit_selector.value["sat:relative_orbit"].values[0]
|
|
187
|
+
return p_sat_direction, p_sat_rel_orbit
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.cell
|
|
191
|
+
def filter_items_by_orbit(p_sat_direction, p_sat_rel_orbit, stac_items_gdf):
|
|
192
|
+
sel_items_gdf = dpolcat.utils.select_by_orbit(
|
|
193
|
+
stac_items_gdf,
|
|
194
|
+
orbit_direction=p_sat_direction,
|
|
195
|
+
relative_orbit=p_sat_rel_orbit,
|
|
196
|
+
)
|
|
197
|
+
sel_items = sel_items_gdf["_item"].values
|
|
198
|
+
return sel_items, sel_items_gdf
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.cell
|
|
202
|
+
def _(sel_items_gdf):
|
|
203
|
+
sel_items_gdf
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.cell
|
|
208
|
+
def get_items_epsg(sel_items):
|
|
209
|
+
_epsgs = set([int(item.properties["proj:code"][5:]) for item in sel_items])
|
|
210
|
+
assert len(_epsgs) == 1, "items are of different CRSs, this is not supported."
|
|
211
|
+
epsg_num = int(_epsgs.pop())
|
|
212
|
+
return (epsg_num,)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.cell
|
|
216
|
+
def plot_footprints(p_aoi, sel_items_gdf):
|
|
217
|
+
# Visualize the AoI and item footprints. Note: AoI geometry may appear a little off due to reprojection compat issue (https://github.com/oceanmodeling/stofs-event-dashboard/pull/36).
|
|
218
|
+
(
|
|
219
|
+
sel_items_gdf.hvplot(geo=True, alpha=0.2, color="orange")
|
|
220
|
+
* p_aoi.hvplot(geo=True, tiles=True, alpha=0.2, color="blue")
|
|
221
|
+
).opts(tools=["wheel_zoom"])
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.cell
|
|
226
|
+
def _():
|
|
227
|
+
mo.md(r"""
|
|
228
|
+
## Processing
|
|
229
|
+
""")
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.cell
|
|
234
|
+
def _():
|
|
235
|
+
ui_existing_zarr_path = mo.ui.text(
|
|
236
|
+
label="Use existing Zarr store (optional):",
|
|
237
|
+
full_width=True,
|
|
238
|
+
placeholder="data/my_existing_dpolcat_store.zarr",
|
|
239
|
+
)
|
|
240
|
+
ui_existing_zarr_path
|
|
241
|
+
return (ui_existing_zarr_path,)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.cell
|
|
245
|
+
def _(ui_existing_zarr_path):
|
|
246
|
+
p_out_zarr_path = ui_existing_zarr_path.value
|
|
247
|
+
return (p_out_zarr_path,)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.cell
|
|
251
|
+
def _():
|
|
252
|
+
ui_speckle_filter = mo.ui.dropdown(options=["Unfiltered", "Frost"], value="Frost")
|
|
253
|
+
ui_speckle_filter
|
|
254
|
+
return (ui_speckle_filter,)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@app.cell
|
|
258
|
+
def _(ui_speckle_filter):
|
|
259
|
+
p_speckle_filter = ui_speckle_filter.value
|
|
260
|
+
return (p_speckle_filter,)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.cell
|
|
264
|
+
def _():
|
|
265
|
+
p_speckle_filter_window = 5
|
|
266
|
+
return (p_speckle_filter_window,)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@app.cell
|
|
270
|
+
def _():
|
|
271
|
+
ui_write_intermediate_data = mo.ui.switch(label="Write intermediate (backscatter) data")
|
|
272
|
+
ui_write_intermediate_data
|
|
273
|
+
return (ui_write_intermediate_data,)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.cell
|
|
277
|
+
def _(ui_write_intermediate_data):
|
|
278
|
+
p_write_intermediate_data = ui_write_intermediate_data.value
|
|
279
|
+
return (p_write_intermediate_data,)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@app.cell
|
|
283
|
+
def _(p_headless, sel_items):
|
|
284
|
+
# Only show run button once valid items have been chosen
|
|
285
|
+
_ = sel_items
|
|
286
|
+
|
|
287
|
+
if not p_headless:
|
|
288
|
+
ui_run = mo.ui.run_button(label="⚙️ Perform processing")
|
|
289
|
+
else:
|
|
290
|
+
# In headless mode, trigger processing with a fake pressed button.
|
|
291
|
+
ui_run = mo.ui.button(value=True)
|
|
292
|
+
|
|
293
|
+
mo.md(f"""
|
|
294
|
+
### Begin processing
|
|
295
|
+
{ui_run}
|
|
296
|
+
"""
|
|
297
|
+
)
|
|
298
|
+
# ui_run
|
|
299
|
+
return (ui_run,)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@app.cell
|
|
303
|
+
def _():
|
|
304
|
+
resolution = 10 # Native to data source
|
|
305
|
+
return (resolution,)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@app.cell
|
|
309
|
+
def _():
|
|
310
|
+
# Chunk size, for both reading (Dask) and writing (Zarr).
|
|
311
|
+
# Chosen for a mix of time series and single-scene operations.
|
|
312
|
+
DASK_CHUNKS = {"time": 10, "y": 1024, "x": 1024}
|
|
313
|
+
ZARR_CHUNKS = (10, 1024, 1024) # ⚠️ be very careful changing this!
|
|
314
|
+
return DASK_CHUNKS, ZARR_CHUNKS
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@app.cell
|
|
318
|
+
def _(DASK_CHUNKS, bbox, epsg_num, resolution, sel_items, ui_run):
|
|
319
|
+
mo.stop(
|
|
320
|
+
not ui_run.value,
|
|
321
|
+
mo.md(
|
|
322
|
+
"Click the [button above](#begin-processing) to start processing."
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Create lazy-loading dataset
|
|
327
|
+
ds = odc.stac.load(
|
|
328
|
+
sel_items,
|
|
329
|
+
bands=["vv", "vh"],
|
|
330
|
+
bbox=bbox,
|
|
331
|
+
crs=f"EPSG:{epsg_num}",
|
|
332
|
+
resolution=resolution,
|
|
333
|
+
chunks={}, # Use default for now, we set this properly after re-sorting.
|
|
334
|
+
groupby="s1:datatake_id",
|
|
335
|
+
nodata=np.nan,
|
|
336
|
+
driver=PCRioDriver(),
|
|
337
|
+
with_properties=[
|
|
338
|
+
{
|
|
339
|
+
"key": "s1:datatake_id",
|
|
340
|
+
"dtype": "uint32",
|
|
341
|
+
"fuser": lambda s: int(s[0]),
|
|
342
|
+
}
|
|
343
|
+
],
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Sort by time (until now, it's sorted by datatake_id, which isn't chronologically ordered).
|
|
347
|
+
# We also need to re-chunk because of this.
|
|
348
|
+
ds = ds.sortby("time").chunk(DASK_CHUNKS)
|
|
349
|
+
|
|
350
|
+
# Add original item IDs as coordinates
|
|
351
|
+
_ids_by_dt = defaultdict(list)
|
|
352
|
+
for _item in sel_items:
|
|
353
|
+
_ids_by_dt[int(_item.properties["s1:datatake_id"])].append(_item.id)
|
|
354
|
+
_item_ids = [
|
|
355
|
+
",".join(sorted(_ids_by_dt[d]))
|
|
356
|
+
for d in ds["s1_datatake_id"].values.astype(int)
|
|
357
|
+
]
|
|
358
|
+
# Convert item id string to an object, otherwise it gets a fixed-length type
|
|
359
|
+
# in the Zarr that breaks if appended data isn't exactly the same shape.
|
|
360
|
+
_item_ids = np.array(_item_ids, dtype=object)
|
|
361
|
+
|
|
362
|
+
ds = ds.assign_coords(item_ids=("time", _item_ids))
|
|
363
|
+
|
|
364
|
+
vv_lin = ds.vv
|
|
365
|
+
vh_lin = ds.vh
|
|
366
|
+
return vh_lin, vv_lin
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@app.cell
|
|
370
|
+
def preprocess(p_speckle_filter, p_speckle_filter_window, vh_lin, vv_lin):
|
|
371
|
+
# Apply speckle filtering and scaling
|
|
372
|
+
|
|
373
|
+
if p_speckle_filter == "Frost":
|
|
374
|
+
FROST_DAMPING_FACTOR = 2.0 # COULDDO: Parameterize if needed
|
|
375
|
+
vv_filt = dpolcat.utils.frost_xr(
|
|
376
|
+
vv_lin,
|
|
377
|
+
nanny=True,
|
|
378
|
+
damping_factor=FROST_DAMPING_FACTOR,
|
|
379
|
+
win_size=p_speckle_filter_window,
|
|
380
|
+
)
|
|
381
|
+
vh_filt = dpolcat.utils.frost_xr(
|
|
382
|
+
vh_lin,
|
|
383
|
+
nanny=True,
|
|
384
|
+
damping_factor=FROST_DAMPING_FACTOR,
|
|
385
|
+
win_size=p_speckle_filter_window,
|
|
386
|
+
)
|
|
387
|
+
vv_db = dp.linear_to_db(vv_filt)
|
|
388
|
+
vh_db = dp.linear_to_db(vh_filt)
|
|
389
|
+
else:
|
|
390
|
+
vv_db = dp.linear_to_db(vv_lin)
|
|
391
|
+
vh_db = dp.linear_to_db(vh_lin)
|
|
392
|
+
return FROST_DAMPING_FACTOR, vh_db, vv_db
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@app.cell(hide_code=True)
|
|
396
|
+
def _():
|
|
397
|
+
mo.md(r"""
|
|
398
|
+
### Metadata
|
|
399
|
+
""")
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@app.function
|
|
404
|
+
def get_dpolcat_version():
|
|
405
|
+
return importlib.metadata.version("dpolcat")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@app.cell
|
|
409
|
+
def _(
|
|
410
|
+
FROST_DAMPING_FACTOR,
|
|
411
|
+
p_sat_direction,
|
|
412
|
+
p_sat_rel_orbit,
|
|
413
|
+
p_speckle_filter,
|
|
414
|
+
p_speckle_filter_window,
|
|
415
|
+
):
|
|
416
|
+
def add_common_metadata(da, epsg_num):
|
|
417
|
+
_da = da.rio.write_crs(epsg_num)
|
|
418
|
+
_wkt = _da.rio.crs.to_wkt()
|
|
419
|
+
_dpolcat_version = get_dpolcat_version()
|
|
420
|
+
_speckle_filter_string = {
|
|
421
|
+
"Frost": f"{p_speckle_filter} (win_size={p_speckle_filter_window}, damping_factor={FROST_DAMPING_FACTOR})",
|
|
422
|
+
"Unfiltered": "Unfiltered",
|
|
423
|
+
}[p_speckle_filter]
|
|
424
|
+
_da = _da.assign_attrs(
|
|
425
|
+
{
|
|
426
|
+
"sat:orbit_state": p_sat_direction,
|
|
427
|
+
"sat:relative_orbit": p_sat_rel_orbit,
|
|
428
|
+
"_CRS": {"wkt": _wkt}, # Required for GDAL compat.
|
|
429
|
+
"dpolcat:speckle_filter": _speckle_filter_string,
|
|
430
|
+
"dpolcat:version": _dpolcat_version,
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
return _da
|
|
435
|
+
|
|
436
|
+
return (add_common_metadata,)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@app.cell
|
|
440
|
+
def _():
|
|
441
|
+
mo.md(r"""
|
|
442
|
+
### Categorization
|
|
443
|
+
""")
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@app.function
|
|
448
|
+
def new_zarr_path(aoi_name):
|
|
449
|
+
timestamp = pd.Timestamp.now("UTC").strftime("%Y%m%dT%H%M%S")
|
|
450
|
+
path = f"data/dpolcat-{aoi_name}_{timestamp}.zarr"
|
|
451
|
+
return path
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@app.cell
|
|
455
|
+
def _(
|
|
456
|
+
ZARR_CHUNKS,
|
|
457
|
+
add_common_metadata,
|
|
458
|
+
epsg_num,
|
|
459
|
+
p_aoi_name,
|
|
460
|
+
p_out_zarr_path,
|
|
461
|
+
p_sat_direction,
|
|
462
|
+
p_sat_rel_orbit,
|
|
463
|
+
p_write_intermediate_data,
|
|
464
|
+
ui_run,
|
|
465
|
+
vh_db,
|
|
466
|
+
vv_db,
|
|
467
|
+
):
|
|
468
|
+
mo.stop(
|
|
469
|
+
not ui_run.value,
|
|
470
|
+
mo.md(
|
|
471
|
+
"Click the [button above](#begin-processing) to start processing."
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Categorization Dask pipeline; write to Zarr
|
|
476
|
+
# --------------
|
|
477
|
+
categorized = dp.xr_categorize(vv_db, vh_db)
|
|
478
|
+
categorized = categorized.rio.write_crs(epsg_num)
|
|
479
|
+
categorized.attrs.pop("nodata", None) # Remove old 'nan' nodata
|
|
480
|
+
# For some reason this breaks plotting later:
|
|
481
|
+
# categorized = categorized.rio.write_nodata(0)
|
|
482
|
+
|
|
483
|
+
out_zarr_path = p_out_zarr_path or new_zarr_path(p_aoi_name)
|
|
484
|
+
zarr_group_path = f"/{p_sat_direction}_{p_sat_rel_orbit}"
|
|
485
|
+
|
|
486
|
+
layers = {"dpolcat": categorized}
|
|
487
|
+
if p_write_intermediate_data:
|
|
488
|
+
layers |= {"vv_db": vv_db, "vh_db": vh_db}
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
zarr.open_group(out_zarr_path, path=zarr_group_path, mode="r")
|
|
492
|
+
_group_exists = True
|
|
493
|
+
except FileNotFoundError, zarr.errors.GroupNotFoundError:
|
|
494
|
+
_group_exists = False
|
|
495
|
+
|
|
496
|
+
_time_encoding = {
|
|
497
|
+
"time": {
|
|
498
|
+
"units": "microseconds since 1970-01-01",
|
|
499
|
+
"dtype": "int64",
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
writes = [
|
|
504
|
+
add_common_metadata(da, epsg_num=epsg_num)
|
|
505
|
+
.to_dataset(name=name)
|
|
506
|
+
.to_zarr(
|
|
507
|
+
out_zarr_path,
|
|
508
|
+
group=f"{zarr_group_path}/{name}",
|
|
509
|
+
mode="a",
|
|
510
|
+
append_dim="time" if _group_exists else None,
|
|
511
|
+
zarr_format=3,
|
|
512
|
+
consolidated=False,
|
|
513
|
+
encoding=(
|
|
514
|
+
None
|
|
515
|
+
if _group_exists
|
|
516
|
+
else _time_encoding | {name: {"chunks": ZARR_CHUNKS}}
|
|
517
|
+
),
|
|
518
|
+
compute=False,
|
|
519
|
+
align_chunks=True,
|
|
520
|
+
)
|
|
521
|
+
for name, da in layers.items()
|
|
522
|
+
]
|
|
523
|
+
with ProgressBar(minimum=1):
|
|
524
|
+
dask.compute(*writes)
|
|
525
|
+
|
|
526
|
+
# Add overviews
|
|
527
|
+
# -------------
|
|
528
|
+
OVERVIEW_LEVELS = [2, 4, 8, 16]
|
|
529
|
+
|
|
530
|
+
# Use this even for continuous data (better physicality and nodata handling)
|
|
531
|
+
OVERVIEW_RESAMPLING = "NEAREST"
|
|
532
|
+
|
|
533
|
+
_gdal_ds = gdal.OpenEx(
|
|
534
|
+
out_zarr_path,
|
|
535
|
+
gdal.OF_MULTIDIM_RASTER | gdal.OF_UPDATE,
|
|
536
|
+
open_options=["USE_CONSOLIDATED_METADATA=NO"],
|
|
537
|
+
)
|
|
538
|
+
_gdal_root = _gdal_ds.GetRootGroup()
|
|
539
|
+
|
|
540
|
+
for _layer in layers:
|
|
541
|
+
_gdal_arr = _gdal_root.OpenMDArrayFromFullname(
|
|
542
|
+
f"{zarr_group_path}/{_layer}/{_layer}"
|
|
543
|
+
)
|
|
544
|
+
_gdal_arr.BuildOverviews(OVERVIEW_RESAMPLING, OVERVIEW_LEVELS)
|
|
545
|
+
|
|
546
|
+
# A note on the Zarr structure: the way multiscales/overviews work means
|
|
547
|
+
# another level of nesting is needed whenever more than 1 variable is present
|
|
548
|
+
# Expect something like, e.g.:
|
|
549
|
+
# /{direction}_{relorbit}/dpolcat/dpolcat (native resolution)
|
|
550
|
+
# /{direction}_{relorbit}/dpolcat/ovr_2x/dpolcat (half resolution)
|
|
551
|
+
# /{direction}_{relorbit}/dpolcat/ovr_4x/dpolcat (quarter resolution)
|
|
552
|
+
# /{direction}_{relorbit}/dpolcat/ovr_*x/dpolcat (etc)
|
|
553
|
+
# /{direction}_{relorbit}/vv_db/vv_db
|
|
554
|
+
# /{direction}_{relorbit}/vv_db/ovr_2x/vv_db
|
|
555
|
+
# etc
|
|
556
|
+
|
|
557
|
+
# Close GDAL items & finalize
|
|
558
|
+
# ----------
|
|
559
|
+
del _gdal_arr, _gdal_root, _gdal_ds
|
|
560
|
+
|
|
561
|
+
zarr.consolidate_metadata(out_zarr_path)
|
|
562
|
+
|
|
563
|
+
result = xarray.open_groups(
|
|
564
|
+
out_zarr_path, engine="zarr", chunks={}, decode_coords="all"
|
|
565
|
+
)
|
|
566
|
+
return OVERVIEW_LEVELS, layers, out_zarr_path, result, zarr_group_path
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@app.cell
|
|
570
|
+
def _(result, zarr_group_path):
|
|
571
|
+
# Results as a more friendly format
|
|
572
|
+
result_ds = xarray.Dataset(
|
|
573
|
+
{"dpolcat": result[f"{zarr_group_path}/dpolcat"]["dpolcat"]}
|
|
574
|
+
)
|
|
575
|
+
if result.get(f"{zarr_group_path}/vv_db") and result.get(
|
|
576
|
+
f"{zarr_group_path}/vh_db"
|
|
577
|
+
):
|
|
578
|
+
result_ds["vv_db"] = result[f"{zarr_group_path}/vv_db"]["vv_db"]
|
|
579
|
+
result_ds["vh_db"] = result[f"{zarr_group_path}/vh_db"]["vh_db"]
|
|
580
|
+
return (result_ds,)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@app.cell(hide_code=True)
|
|
584
|
+
def _():
|
|
585
|
+
mo.md(r"""
|
|
586
|
+
## Visualization
|
|
587
|
+
""")
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@app.cell
|
|
592
|
+
def _():
|
|
593
|
+
ui_viz_enabled = mo.ui.switch(label="Viz enabled")
|
|
594
|
+
ui_viz_enabled
|
|
595
|
+
return (ui_viz_enabled,)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@app.cell
|
|
599
|
+
def _(ui_viz_enabled):
|
|
600
|
+
mo.stop(not ui_viz_enabled.value)
|
|
601
|
+
|
|
602
|
+
preview_resolution = 40
|
|
603
|
+
return (preview_resolution,)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@app.cell
|
|
607
|
+
def _(preview_resolution, resolution, result_ds):
|
|
608
|
+
_step = preview_resolution // resolution
|
|
609
|
+
result_preview = result_ds.isel(x=slice(None, None, _step), y=slice(None, None, _step))
|
|
610
|
+
# SHOULDDO: load using multiscale, and maybe use datashader rasterize
|
|
611
|
+
|
|
612
|
+
# result_preview = rioxarray.open_rasterio(
|
|
613
|
+
# out_zarr_path
|
|
614
|
+
# )
|
|
615
|
+
|
|
616
|
+
# # Have to re-add time coordinates
|
|
617
|
+
# result_preview = result_preview.rename(band="time").assign_coords(
|
|
618
|
+
# time=result.time.values
|
|
619
|
+
# )
|
|
620
|
+
return (result_preview,)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@app.cell
|
|
624
|
+
def _(p_write_intermediate_data, result_preview):
|
|
625
|
+
# Result preview.
|
|
626
|
+
|
|
627
|
+
def plot_backscatter(da):
|
|
628
|
+
return da.hvplot.image(
|
|
629
|
+
x="x", y="y", cmap="gray", data_aspect=1.0, clim=(-35, 15)
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
plot_width = 280
|
|
633
|
+
|
|
634
|
+
# Setup linked crosshairs,
|
|
635
|
+
# via https://discourse.holoviz.org/t/linked-crosshair/4326/2
|
|
636
|
+
_width = bokeh.models.Span(dimension="width")
|
|
637
|
+
_height = bokeh.models.Span(dimension="height")
|
|
638
|
+
linked_crosshair = bokeh.models.CrosshairTool(overlay=[_width, _height])
|
|
639
|
+
|
|
640
|
+
_dpolcat = result_preview["dpolcat"].astype("str")
|
|
641
|
+
_dpolcat_plot = _dpolcat.hvplot(
|
|
642
|
+
x="x",
|
|
643
|
+
y="y",
|
|
644
|
+
data_aspect=1.0,
|
|
645
|
+
cmap=dpolcat.utils.make_cmap(),
|
|
646
|
+
frame_width=plot_width,
|
|
647
|
+
tools=[linked_crosshair],
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
if p_write_intermediate_data:
|
|
651
|
+
_plot = (
|
|
652
|
+
_dpolcat_plot
|
|
653
|
+
+ dpolcat.utils.make_legend(size=250).opts(width=350)
|
|
654
|
+
+ plot_backscatter(result_preview["vv_db"]).opts(
|
|
655
|
+
frame_width=plot_width, tools=[linked_crosshair]
|
|
656
|
+
)
|
|
657
|
+
+ plot_backscatter(result_preview["vh_db"]).opts(
|
|
658
|
+
frame_width=plot_width, tools=[linked_crosshair]
|
|
659
|
+
)
|
|
660
|
+
).cols(2)
|
|
661
|
+
else:
|
|
662
|
+
_plot = (_dpolcat_plot + dpolcat.utils.make_legend(size=250)).cols(2)
|
|
663
|
+
_plot
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@app.cell
|
|
668
|
+
def _(result_preview, ui_viz_enabled):
|
|
669
|
+
mo.stop(not ui_viz_enabled.value)
|
|
670
|
+
|
|
671
|
+
_bins = list(range(0, dp.NUM_CATEGORIES + 1))
|
|
672
|
+
result_preview["dpolcat"].hvplot.hist(bins=_bins).opts(
|
|
673
|
+
tools=["wheel_zoom"]
|
|
674
|
+
)
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@app.cell(hide_code=True)
|
|
679
|
+
def _():
|
|
680
|
+
mo.md(r"""
|
|
681
|
+
### Create .VRTs
|
|
682
|
+
|
|
683
|
+
Create .VRT files for the Zarr assets that provide overview (multiscale) support, e.g., for QGIS.
|
|
684
|
+
|
|
685
|
+
/// attention
|
|
686
|
+
|
|
687
|
+
These point to the absolute path of the .zarr store.
|
|
688
|
+
So, if this is moved, the VRTs will have to be updated/recreated.
|
|
689
|
+
""")
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
@app.cell
|
|
694
|
+
def _():
|
|
695
|
+
ui_vrt = mo.ui.run_button(label="Create .VRTs")
|
|
696
|
+
ui_vrt
|
|
697
|
+
return (ui_vrt,)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@app.cell
|
|
701
|
+
def _(OVERVIEW_LEVELS, layers, out_zarr_path, ui_vrt, zarr_group_path):
|
|
702
|
+
mo.stop(not ui_vrt.value, "Click the button above to create .VRT files.")
|
|
703
|
+
|
|
704
|
+
def zarr_layer_vrt(zarr_path, layer, levels, vrt_path=None):
|
|
705
|
+
"""Creates .VRT containing overviews for each layer."""
|
|
706
|
+
# Mostly Claude (Opus 5.5)
|
|
707
|
+
zarr_path = Path(zarr_path).absolute()
|
|
708
|
+
src = f'ZARR:"{zarr_path}":{zarr_group_path}/{layer}'
|
|
709
|
+
vrt_path = vrt_path or f"data/{zarr_path.stem}_{layer}.vrt"
|
|
710
|
+
gdal.Translate(
|
|
711
|
+
vrt_path,
|
|
712
|
+
f"{src}/{layer}",
|
|
713
|
+
format="VRT",
|
|
714
|
+
)
|
|
715
|
+
tree = ET.parse(vrt_path)
|
|
716
|
+
for band in tree.getroot().iter("VRTRasterBand"):
|
|
717
|
+
for f in levels:
|
|
718
|
+
ovr = ET.SubElement(band, "Overview")
|
|
719
|
+
ET.SubElement(
|
|
720
|
+
ovr, "SourceFilename"
|
|
721
|
+
).text = f"{src}/ovr_{f}x/{layer}"
|
|
722
|
+
ET.SubElement(ovr, "SourceBand").text = band.get("band")
|
|
723
|
+
tree.write(vrt_path)
|
|
724
|
+
return vrt_path
|
|
725
|
+
|
|
726
|
+
vrts = {
|
|
727
|
+
layer: zarr_layer_vrt(out_zarr_path, layer, OVERVIEW_LEVELS)
|
|
728
|
+
for layer in layers
|
|
729
|
+
}
|
|
730
|
+
return (vrts,)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
@app.cell
|
|
734
|
+
def _(vrts):
|
|
735
|
+
vrts
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
@app.cell(hide_code=True)
|
|
740
|
+
def _():
|
|
741
|
+
mo.md(r"""
|
|
742
|
+
### COG export
|
|
743
|
+
""")
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
@app.cell
|
|
748
|
+
def _():
|
|
749
|
+
ui_export = mo.ui.run_button(label="Export COGs")
|
|
750
|
+
ui_export
|
|
751
|
+
return (ui_export,)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
@app.cell
|
|
755
|
+
def _(aoi_name, result_ds, ui_export):
|
|
756
|
+
mo.stop(not ui_export.value, "Click the button above to export.")
|
|
757
|
+
|
|
758
|
+
exported_cogs = []
|
|
759
|
+
for timeslice in result_ds["dpolcat"]:
|
|
760
|
+
_date = str(timeslice["time"].values)[:10]
|
|
761
|
+
_filename = f"data/dpolcat-{aoi_name}-{_date}.tif"
|
|
762
|
+
_da = timeslice
|
|
763
|
+
# _da = timeslice.astype(np.uint8).rename("dpolcat")
|
|
764
|
+
_da = _da.rio.write_nodata(0)
|
|
765
|
+
_da.rio.to_raster(_filename, compute=True)
|
|
766
|
+
exported_cogs.append(_filename)
|
|
767
|
+
return (exported_cogs,)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@app.cell
|
|
771
|
+
def _(exported_cogs):
|
|
772
|
+
exported_cogs
|
|
773
|
+
return
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
if __name__ == "__main__":
|
|
777
|
+
app.run()
|