paddocktimeseries 0.8.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.
- PaddockTS/FractionalCover/__init__.py +1 -0
- PaddockTS/FractionalCover/_models/__init__.py +0 -0
- PaddockTS/FractionalCover/_models/fcModel_256x128x256.tflite +0 -0
- PaddockTS/FractionalCover/_models/fcModel_256x64x256.tflite +0 -0
- PaddockTS/FractionalCover/_models/fcModel_32x32x32.tflite +0 -0
- PaddockTS/FractionalCover/_models/fcModel_64x64x64.tflite +0 -0
- PaddockTS/FractionalCover/_unmix.py +45 -0
- PaddockTS/FractionalCover/check_if_valid_fractional_cover_exists.py +49 -0
- PaddockTS/FractionalCover/compute_fractional_cover.py +208 -0
- PaddockTS/PaddockSegmentation/__init__.py +0 -0
- PaddockTS/PaddockSegmentation/_presegment.py +130 -0
- PaddockTS/PaddockSegmentation/check_if_valid_paddocks_exists.py +60 -0
- PaddockTS/PaddockSegmentation/check_if_valid_preseg_exists.py +51 -0
- PaddockTS/PaddockSegmentation/get_paddocks.py +232 -0
- PaddockTS/Phenology/__init__.py +0 -0
- PaddockTS/Phenology/_phenolopy.py +2525 -0
- PaddockTS/Phenology/estimate_phenology.py +151 -0
- PaddockTS/Phenology/make_paddock_time_series.py +233 -0
- PaddockTS/Phenology/make_smoothed_paddock_time_series.py +157 -0
- PaddockTS/Phenology/make_yearly_paddock_time_series.py +103 -0
- PaddockTS/Plotting/__init__.py +24 -0
- PaddockTS/Plotting/calendar_plot.py +523 -0
- PaddockTS/Plotting/fractional_cover_paddocks_video.py +157 -0
- PaddockTS/Plotting/fractional_cover_video.py +117 -0
- PaddockTS/Plotting/make_pdf.py +230 -0
- PaddockTS/Plotting/ozwald_plot.py +138 -0
- PaddockTS/Plotting/phenology_plot.py +200 -0
- PaddockTS/Plotting/sentinel2_paddocks_video.py +141 -0
- PaddockTS/Plotting/sentinel2_video.py +107 -0
- PaddockTS/Plotting/silo_plot.py +95 -0
- PaddockTS/Plotting/terrain_derivatives.py +213 -0
- PaddockTS/Plotting/terrain_tiles_plot.py +207 -0
- PaddockTS/__init__.py +0 -0
- PaddockTS/check_status.py +15 -0
- PaddockTS/daesim_forcing.py +146 -0
- PaddockTS/get_outputs.py +688 -0
- PaddockTS/paths.py +110 -0
- PaddockTS/run_environmental.py +180 -0
- PaddockTS/status.py +12 -0
- PaddockTS/utils.py +79 -0
- PaddockTS/viewer/__init__.py +13 -0
- PaddockTS/viewer/__main__.py +18 -0
- PaddockTS/viewer/app.py +149 -0
- PaddockTS/viewer/scan.py +196 -0
- PaddockTS/viewer/serve.py +80 -0
- paddocktimeseries-0.8.0.dist-info/METADATA +459 -0
- paddocktimeseries-0.8.0.dist-info/RECORD +50 -0
- paddocktimeseries-0.8.0.dist-info/WHEEL +5 -0
- paddocktimeseries-0.8.0.dist-info/licenses/LICENSE +21 -0
- paddocktimeseries-0.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .compute_fractional_cover import compute_fractional_cover
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Fractional cover unmixing.
|
|
2
|
+
|
|
3
|
+
Inference logic and the bundled tflite model files in `_models/` are adapted
|
|
4
|
+
from the fractionalcover3 package by Robert Denham, distributed under the
|
|
5
|
+
MIT License. See PaddockTS/LICENSES/fractionalcover3.LICENSE.
|
|
6
|
+
"""
|
|
7
|
+
from importlib import resources
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from tensorflow import lite as tflite
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_MODELS = (
|
|
14
|
+
"fcModel_32x32x32.tflite",
|
|
15
|
+
"fcModel_64x64x64.tflite",
|
|
16
|
+
"fcModel_256x64x256.tflite",
|
|
17
|
+
"fcModel_256x128x256.tflite",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_model(n: int = 2):
|
|
22
|
+
"""Load the n-th bundled fractional cover tflite model (1-indexed, 1-4)."""
|
|
23
|
+
path = resources.files(__package__) / "_models" / _MODELS[n - 1]
|
|
24
|
+
return tflite.Interpreter(model_path=str(path))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def unmix_fractional_cover(surface_reflectance, fc_model, in_null=0, out_null=0):
|
|
28
|
+
"""Unmix a 6-band surface reflectance array into bare/green/non-green fractions.
|
|
29
|
+
|
|
30
|
+
The blue band is dropped before inference (the model was trained on the
|
|
31
|
+
remaining 5 bands). Returns a (3, nrows, ncols) float32 array.
|
|
32
|
+
"""
|
|
33
|
+
inshape = surface_reflectance[1:].shape
|
|
34
|
+
ref_data = np.reshape(surface_reflectance[1:], (inshape[0], -1)).T
|
|
35
|
+
|
|
36
|
+
input_details = fc_model.get_input_details()
|
|
37
|
+
output_details = fc_model.get_output_details()
|
|
38
|
+
fc_model.resize_tensor_input(input_details[0]["index"], ref_data.shape)
|
|
39
|
+
fc_model.allocate_tensors()
|
|
40
|
+
fc_model.set_tensor(input_details[0]["index"], ref_data.astype(np.float32))
|
|
41
|
+
fc_model.invoke()
|
|
42
|
+
fc_layers = fc_model.get_tensor(output_details[0]["index"]).T
|
|
43
|
+
output_fc = np.reshape(fc_layers, (3, inshape[1], inshape[2]))
|
|
44
|
+
output_fc[output_fc == in_null] = out_null
|
|
45
|
+
return output_fc
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Cache-validity check for the computed fractional-cover zarr.
|
|
2
|
+
|
|
3
|
+
A valid cache requires both the zarr directory and a ``_SUCCESS`` marker
|
|
4
|
+
file *inside* it (written by :func:`compute_fractional_cover` only after
|
|
5
|
+
the zarr write completes). Without the marker the zarr is treated as a
|
|
6
|
+
partial / interrupted write and recomputed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from os.path import exists
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_if_valid_fractional_cover_exists(fractional_cover_path: str) -> bool:
|
|
13
|
+
"""Return True iff a successfully-written fractional-cover zarr is on disk."""
|
|
14
|
+
return exists(fractional_cover_path) and exists(f'{fractional_cover_path}/_SUCCESS')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_no_zarr_no_marker():
|
|
18
|
+
import tempfile
|
|
19
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
20
|
+
return check_if_valid_fractional_cover_exists(f'{tmpdir}/missing.zarr') is False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_zarr_without_marker():
|
|
24
|
+
import tempfile, os
|
|
25
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
26
|
+
zarr_path = f'{tmpdir}/data.zarr'
|
|
27
|
+
os.makedirs(zarr_path)
|
|
28
|
+
return check_if_valid_fractional_cover_exists(zarr_path) is False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_zarr_with_marker():
|
|
32
|
+
import tempfile, os
|
|
33
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
34
|
+
zarr_path = f'{tmpdir}/data.zarr'
|
|
35
|
+
os.makedirs(zarr_path)
|
|
36
|
+
open(f'{zarr_path}/_SUCCESS', 'w').close()
|
|
37
|
+
return check_if_valid_fractional_cover_exists(zarr_path) is True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test():
|
|
41
|
+
return all([
|
|
42
|
+
test_no_zarr_no_marker(),
|
|
43
|
+
test_zarr_without_marker(),
|
|
44
|
+
test_zarr_with_marker(),
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == '__main__':
|
|
49
|
+
print(test())
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Spectral unmixing of Sentinel-2 reflectance into bg / pv / npv fractions.
|
|
2
|
+
|
|
3
|
+
The model is a small TFLite MLP adapted from
|
|
4
|
+
`fractionalcover3 <https://github.com/jrsrp/fractionalcover3>`_ by
|
|
5
|
+
Robert Denham (MIT-licensed; see
|
|
6
|
+
``PaddockTS/LICENSES/fractionalcover3.LICENSE``). Four model variants
|
|
7
|
+
ship with the package, indexed ``n=1..4`` from least to most complex;
|
|
8
|
+
``n=4`` is the default and most accurate.
|
|
9
|
+
|
|
10
|
+
Output bands:
|
|
11
|
+
|
|
12
|
+
- ``bg`` — bare ground fraction
|
|
13
|
+
- ``pv`` — green (photosynthetic) vegetation fraction
|
|
14
|
+
- ``npv`` — non-green (non-photosynthetic) vegetation fraction
|
|
15
|
+
|
|
16
|
+
Fractions are produced per-pixel per-timestep and persisted to
|
|
17
|
+
``Paths(troi).fractional_cover`` as Zarr v2. Input reflectance comes
|
|
18
|
+
from the machine-wide pysentinel2 cube (cloud-masked, on read).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import warnings
|
|
23
|
+
|
|
24
|
+
os.environ.setdefault('TF_CPP_MIN_LOG_LEVEL', '3')
|
|
25
|
+
warnings.filterwarnings('ignore', module='tensorflow')
|
|
26
|
+
warnings.filterwarnings('ignore', module='keras')
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
import xarray as xr
|
|
30
|
+
from os import makedirs
|
|
31
|
+
from os.path import exists
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
from xarray import Dataset
|
|
34
|
+
from troi import Troi
|
|
35
|
+
from PaddockTS.paths import Paths
|
|
36
|
+
from PaddockTS.FractionalCover.check_if_valid_fractional_cover_exists import check_if_valid_fractional_cover_exists
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
BANDS = ['nbart_blue', 'nbart_green', 'nbart_red', 'nbart_nir_1', 'nbart_swir_2', 'nbart_swir_3']
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compute_fractional_cover(troi: Troi, ds_sentinel2=None, model_n: int = 4, correction: bool = False):
|
|
43
|
+
"""Run the TFLite unmixing model over every Sentinel-2 timestep.
|
|
44
|
+
|
|
45
|
+
Stacks the six Sentinel-2 SR bands into a ``(time, band, y, x)``
|
|
46
|
+
tensor, scales reflectance, and invokes the chosen model variant
|
|
47
|
+
once per timestep. The result is written to
|
|
48
|
+
``Paths(troi).fractional_cover`` and returned as an xarray Dataset
|
|
49
|
+
with ``bg``, ``pv``, ``npv`` data variables on dims
|
|
50
|
+
``(time, y, x)``.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
troi: The :class:`troi.Troi`.
|
|
54
|
+
ds_sentinel2: Optional in-memory Sentinel-2 dataset. If ``None``,
|
|
55
|
+
the cloud-masked window is read from the machine-wide
|
|
56
|
+
pysentinel2 cube (downloading only what's missing). Must
|
|
57
|
+
contain the six bands in :data:`BANDS`.
|
|
58
|
+
model_n: Which bundled model variant to use (``1..4``). Higher
|
|
59
|
+
``n`` is more accurate but slower; ``n=4`` is the default.
|
|
60
|
+
correction: If ``True``, apply per-band sensor calibration
|
|
61
|
+
factors (gains and offsets fitted in the upstream
|
|
62
|
+
fractionalcover3 work) instead of the simple ``* 0.0001``
|
|
63
|
+
DN-to-reflectance scaling. Use only when your inputs match
|
|
64
|
+
the calibration assumptions of the original model.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
xarray.Dataset: Dataset with variables ``bg``, ``pv``, ``npv``
|
|
68
|
+
on dims ``(time, y, x)``. Also persisted to
|
|
69
|
+
``Paths(troi).fractional_cover``.
|
|
70
|
+
"""
|
|
71
|
+
from PaddockTS.FractionalCover._unmix import unmix_fractional_cover, get_model
|
|
72
|
+
|
|
73
|
+
fractional_cover_path = Paths(troi).fractional_cover
|
|
74
|
+
if check_if_valid_fractional_cover_exists(fractional_cover_path):
|
|
75
|
+
try:
|
|
76
|
+
return xr.open_zarr(fractional_cover_path, chunks=None, decode_coords='all')
|
|
77
|
+
except Exception as e:
|
|
78
|
+
print(f'Fractional-cover cache at {fractional_cover_path} unreadable ({e}); recomputing')
|
|
79
|
+
|
|
80
|
+
if ds_sentinel2 is None:
|
|
81
|
+
from pysentinel2.cube import Cube
|
|
82
|
+
ds = Cube(config=troi.config).get_ds_troi(troi, clean=True)
|
|
83
|
+
else:
|
|
84
|
+
ds = ds_sentinel2
|
|
85
|
+
|
|
86
|
+
if correction:
|
|
87
|
+
factors = (np.array([0.9551, 1.0582, 0.9871, 1.0187, 0.9528, 0.9688]) +
|
|
88
|
+
np.array([-0.0022, 0.0031, 0.0064, 0.012, 0.0079, -0.0042])
|
|
89
|
+
).astype(np.float32)[:, np.newaxis, np.newaxis]
|
|
90
|
+
else:
|
|
91
|
+
factors = np.float32(0.0001)
|
|
92
|
+
|
|
93
|
+
# Stream in small time batches, appending each to the zarr as it's
|
|
94
|
+
# unmixed. Materialising the whole window at once — six bands x every
|
|
95
|
+
# timestep as float — needs >10 GB for a multi-year troi and OOMs
|
|
96
|
+
# 8 GB machines; a 32-frame batch stays around 200 MB regardless of
|
|
97
|
+
# the time range. The model is loaded once, not per timestep.
|
|
98
|
+
#
|
|
99
|
+
# Unmixing runs serially on purpose: 4 threads with per-thread
|
|
100
|
+
# interpreters measured 615.2 s vs 618.3 s serial on an 8-year window
|
|
101
|
+
# (340% CPU for zero wall gain — the matmul is memory-bandwidth
|
|
102
|
+
# bound). If this step ever needs to be faster, batch several frames
|
|
103
|
+
# into one Invoke rather than adding threads.
|
|
104
|
+
model = get_model(n=model_n)
|
|
105
|
+
makedirs(os.path.dirname(fractional_cover_path), exist_ok=True)
|
|
106
|
+
timestamp = datetime.utcnow().isoformat() + 'Z'
|
|
107
|
+
|
|
108
|
+
n_time = ds.sizes['time']
|
|
109
|
+
batch = 32
|
|
110
|
+
for t0 in range(0, n_time, batch):
|
|
111
|
+
sub = ds.isel(time=slice(t0, t0 + batch))
|
|
112
|
+
inref = np.stack([sub[b].values for b in BANDS], axis=1).astype(np.float32)
|
|
113
|
+
# Cleaned cube bands are int16 with masked pixels at the nodata
|
|
114
|
+
# sentinel; convert to NaN so the unmixing propagates missingness
|
|
115
|
+
# instead of ingesting -999 as reflectance.
|
|
116
|
+
for bi, b in enumerate(BANDS):
|
|
117
|
+
nodata = ds[b].attrs.get('nodata')
|
|
118
|
+
if nodata is not None:
|
|
119
|
+
inref[:, bi][inref[:, bi] == float(nodata)] = np.nan
|
|
120
|
+
inref[inref == 0] = np.nan
|
|
121
|
+
inref *= factors
|
|
122
|
+
fractions = np.empty((inref.shape[0], 3, inref.shape[2], inref.shape[3]),
|
|
123
|
+
dtype=np.float32)
|
|
124
|
+
for i in range(inref.shape[0]):
|
|
125
|
+
fractions[i] = unmix_fractional_cover(inref[i], fc_model=model)
|
|
126
|
+
|
|
127
|
+
coords = {'time': sub.time, 'y': ds.y, 'x': ds.x}
|
|
128
|
+
batch_ds = xr.Dataset({
|
|
129
|
+
'bg': xr.DataArray(fractions[:, 0], dims=['time', 'y', 'x'], coords=coords),
|
|
130
|
+
'pv': xr.DataArray(fractions[:, 1], dims=['time', 'y', 'x'], coords=coords),
|
|
131
|
+
'npv': xr.DataArray(fractions[:, 2], dims=['time', 'y', 'x'], coords=coords),
|
|
132
|
+
})
|
|
133
|
+
if t0 == 0:
|
|
134
|
+
batch_ds = batch_ds.assign_attrs(fractional_cover_computed_at=timestamp)
|
|
135
|
+
batch_ds.to_zarr(fractional_cover_path, mode='w', zarr_format=2,
|
|
136
|
+
encoding={v: {'chunks': (1, ds.sizes['y'], ds.sizes['x'])}
|
|
137
|
+
for v in ('bg', 'pv', 'npv')})
|
|
138
|
+
else:
|
|
139
|
+
batch_ds.to_zarr(fractional_cover_path, append_dim='time', zarr_format=2)
|
|
140
|
+
|
|
141
|
+
# Touch _SUCCESS *after* the zarr write completes; its presence is what
|
|
142
|
+
# the next call uses as the cache-validity check.
|
|
143
|
+
with open(f'{fractional_cover_path}/_SUCCESS', 'w') as f:
|
|
144
|
+
f.write(timestamp)
|
|
145
|
+
# Re-open from disk: a lazy, zarr-backed view rather than the in-RAM
|
|
146
|
+
# batches — downstream consumers slice what they need.
|
|
147
|
+
return xr.open_zarr(fractional_cover_path, chunks=None, decode_coords='all')
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _temp_troi():
|
|
151
|
+
import tempfile
|
|
152
|
+
from datetime import date
|
|
153
|
+
from troi import Config
|
|
154
|
+
tmpdir = tempfile.mkdtemp(prefix='paddockts_fc_test_')
|
|
155
|
+
cfg = Config(out_dir=tmpdir, tmp_dir=tmpdir)
|
|
156
|
+
return Troi(
|
|
157
|
+
bbox=[148.36265, -33.52606, 148.38265, -33.50606],
|
|
158
|
+
start=date(2024, 1, 1), end=date(2024, 1, 21),
|
|
159
|
+
stub=f'fc_{os.path.basename(tmpdir)}', config=cfg,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_compute_writes_zarr_and_marker():
|
|
164
|
+
"""First call computes, writes fractional_cover.zarr, and touches _SUCCESS."""
|
|
165
|
+
q = _temp_troi()
|
|
166
|
+
ds = compute_fractional_cover(q)
|
|
167
|
+
fc = Paths(q).fractional_cover
|
|
168
|
+
if not exists(fc):
|
|
169
|
+
return False
|
|
170
|
+
if not exists(f'{fc}/_SUCCESS'):
|
|
171
|
+
return False
|
|
172
|
+
return all(name in ds.data_vars for name in ('bg', 'pv', 'npv'))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_repeated_call_uses_cache():
|
|
176
|
+
"""Second call with same troi reuses the zarr (no rewrite)."""
|
|
177
|
+
q = _temp_troi()
|
|
178
|
+
compute_fractional_cover(q)
|
|
179
|
+
fc = Paths(q).fractional_cover
|
|
180
|
+
mtime_before = os.path.getmtime(fc)
|
|
181
|
+
compute_fractional_cover(q)
|
|
182
|
+
mtime_after = os.path.getmtime(fc)
|
|
183
|
+
return mtime_before == mtime_after
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_missing_marker_triggers_recompute():
|
|
187
|
+
"""A cache with the zarr present but no _SUCCESS file is recomputed."""
|
|
188
|
+
q = _temp_troi()
|
|
189
|
+
compute_fractional_cover(q)
|
|
190
|
+
fc = Paths(q).fractional_cover
|
|
191
|
+
marker = f'{fc}/_SUCCESS'
|
|
192
|
+
os.remove(marker)
|
|
193
|
+
mtime_before = os.path.getmtime(fc)
|
|
194
|
+
compute_fractional_cover(q)
|
|
195
|
+
mtime_after = os.path.getmtime(fc)
|
|
196
|
+
return exists(marker) and mtime_after > mtime_before
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test():
|
|
200
|
+
return all([
|
|
201
|
+
test_compute_writes_zarr_and_marker(),
|
|
202
|
+
test_repeated_call_uses_cache(),
|
|
203
|
+
test_missing_marker_triggers_recompute(),
|
|
204
|
+
])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == '__main__':
|
|
208
|
+
print(test())
|
|
File without changes
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import xarray as xr
|
|
3
|
+
from numpy.typing import NDArray
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from os import makedirs
|
|
6
|
+
from os.path import exists, dirname
|
|
7
|
+
from troi import Troi
|
|
8
|
+
from PaddockTS.paths import Paths
|
|
9
|
+
from PaddockTS.PaddockSegmentation.check_if_valid_preseg_exists import check_if_valid_preseg_exists
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def completion(arr):
|
|
13
|
+
"""Gap-fill NaN values in a (H, W, T) time series by forward-filling then backfilling."""
|
|
14
|
+
mask = np.isnan(arr)
|
|
15
|
+
idx = np.where(~mask, np.arange(mask.shape[-1]), 0)
|
|
16
|
+
np.maximum.accumulate(idx, axis=-1, out=idx)
|
|
17
|
+
i, j = np.meshgrid(
|
|
18
|
+
np.arange(idx.shape[0]), np.arange(idx.shape[1]), indexing="ij"
|
|
19
|
+
)
|
|
20
|
+
dat = arr[i[:, :, np.newaxis], j[:, :, np.newaxis], idx]
|
|
21
|
+
if np.isnan(np.sum(dat[:, :, 0])):
|
|
22
|
+
fill = np.nanmean(dat, axis=-1)
|
|
23
|
+
for t in range(dat.shape[-1]):
|
|
24
|
+
m = np.isnan(dat[:, :, t])
|
|
25
|
+
if m.any():
|
|
26
|
+
dat[m, t] = fill[m]
|
|
27
|
+
else:
|
|
28
|
+
break
|
|
29
|
+
return dat
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fourier_mean(x, n=3, step=5):
|
|
33
|
+
"""Extract n Fourier band means from a (H, W, T) time series -> (H, W, n)."""
|
|
34
|
+
spectra = np.abs(np.fft.fft(x, axis=2))
|
|
35
|
+
result = np.empty((x.shape[0], x.shape[1], n), dtype=np.float32)
|
|
36
|
+
for k in range(n):
|
|
37
|
+
result[:, :, k] = np.mean(spectra[:, :, 1 + k * step:(k + 1) * step + 1], axis=2)
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def compute_ndwi_fourier(ds: xr.Dataset) -> NDArray[np.float32]:
|
|
42
|
+
"""Compute NDWI from sentinel-2 bands, gap-fill, then Fourier summary -> (H, W, 3)."""
|
|
43
|
+
green = ds["nbart_green"].transpose("y", "x", "time").values.astype(np.float32)
|
|
44
|
+
nir = ds["nbart_nir_1"].transpose("y", "x", "time").values.astype(np.float32)
|
|
45
|
+
|
|
46
|
+
# 0 = unobserved; negative = the cleaned cube's int16 nodata sentinel
|
|
47
|
+
# (-999). Both are missing.
|
|
48
|
+
green[green <= 0] = np.nan
|
|
49
|
+
nir[nir <= 0] = np.nan
|
|
50
|
+
green /= 10000.0
|
|
51
|
+
nir /= 10000.0
|
|
52
|
+
|
|
53
|
+
ndwi_obs = (green - nir) / (green + nir)
|
|
54
|
+
ndwi_obs[~np.isfinite(ndwi_obs)] = np.nan
|
|
55
|
+
|
|
56
|
+
ndwi = completion(ndwi_obs)
|
|
57
|
+
return fourier_mean(ndwi).astype(np.float32)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def rescale_uint8(im: NDArray[np.float32]) -> NDArray[np.uint8]:
|
|
61
|
+
"""Per-band min-max normalization to [0, 255] uint8."""
|
|
62
|
+
if im.ndim == 2:
|
|
63
|
+
im = im[:, :, None]
|
|
64
|
+
out = np.empty_like(im, dtype=np.uint8)
|
|
65
|
+
for i in range(im.shape[2]):
|
|
66
|
+
band = im[:, :, i]
|
|
67
|
+
finite = np.isfinite(band)
|
|
68
|
+
vmin, vmax = np.nanmin(band), np.nanmax(band)
|
|
69
|
+
if not np.any(finite) or vmax <= vmin:
|
|
70
|
+
out[:, :, i] = 0
|
|
71
|
+
continue
|
|
72
|
+
scaled = np.clip((band - vmin) / (vmax - vmin), 0, 1)
|
|
73
|
+
scaled[~finite] = 0.0
|
|
74
|
+
out[:, :, i] = (scaled * 255).astype(np.uint8)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def save_geotiff(ds: xr.Dataset, uint8_image: NDArray[np.uint8], path: str) -> None:
|
|
79
|
+
"""Save a 3-band uint8 image as a georeferenced GeoTIFF using CRS/transform from ds."""
|
|
80
|
+
import rioxarray # noqa: F401
|
|
81
|
+
|
|
82
|
+
if uint8_image.ndim == 2:
|
|
83
|
+
uint8_image = np.repeat(uint8_image[:, :, None], 3, axis=2)
|
|
84
|
+
elif uint8_image.shape[2] == 1:
|
|
85
|
+
uint8_image = np.repeat(uint8_image, 3, axis=2)
|
|
86
|
+
elif uint8_image.shape[2] == 2:
|
|
87
|
+
uint8_image = np.concatenate([uint8_image, uint8_image[:, :, :1]], axis=2)
|
|
88
|
+
elif uint8_image.shape[2] > 3:
|
|
89
|
+
uint8_image = uint8_image[:, :, :3]
|
|
90
|
+
|
|
91
|
+
da = xr.DataArray(
|
|
92
|
+
uint8_image,
|
|
93
|
+
coords={"y": ds.y.values, "x": ds.x.values, "band": np.arange(1, 4)},
|
|
94
|
+
dims=("y", "x", "band"),
|
|
95
|
+
)
|
|
96
|
+
da.rio.set_spatial_dims(x_dim="x", y_dim="y", inplace=True)
|
|
97
|
+
if ds.rio.crs is not None:
|
|
98
|
+
da.rio.write_crs(ds.rio.crs, inplace=True)
|
|
99
|
+
try:
|
|
100
|
+
da.rio.write_transform(ds.rio.transform(), inplace=True)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
da.transpose("band", "y", "x").rio.to_raster(path)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def presegment(troi: Troi, ds_sentinel2=None) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Create a 3-band uint8 NDWI Fourier GeoTIFF from Sentinel-2 data.
|
|
109
|
+
Returns the path to the saved preseg GeoTIFF.
|
|
110
|
+
"""
|
|
111
|
+
preseg_path = Paths(troi).preseg
|
|
112
|
+
if check_if_valid_preseg_exists(preseg_path):
|
|
113
|
+
return preseg_path
|
|
114
|
+
|
|
115
|
+
if ds_sentinel2 is None:
|
|
116
|
+
from pysentinel2.cube import Cube
|
|
117
|
+
ds = Cube(config=troi.config).get_ds_troi(troi, clean=True)
|
|
118
|
+
else:
|
|
119
|
+
ds = ds_sentinel2
|
|
120
|
+
features = compute_ndwi_fourier(ds)
|
|
121
|
+
uint8_image = rescale_uint8(features)
|
|
122
|
+
|
|
123
|
+
makedirs(dirname(preseg_path), exist_ok=True)
|
|
124
|
+
save_geotiff(ds, uint8_image, preseg_path)
|
|
125
|
+
# Touch ``<tif>._SUCCESS`` *after* the TIFF write completes; its presence
|
|
126
|
+
# is what the next call uses as the cache-validity check.
|
|
127
|
+
with open(f'{preseg_path}._SUCCESS', 'w') as f:
|
|
128
|
+
f.write(datetime.utcnow().isoformat() + 'Z')
|
|
129
|
+
print(f"Saved preseg to {preseg_path}")
|
|
130
|
+
return preseg_path
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Cache-validity check for the SAM-segmented paddocks GeoPackage.
|
|
2
|
+
|
|
3
|
+
A valid cache requires both the ``.gpkg`` file and a ``.gpkg._SUCCESS``
|
|
4
|
+
sentinel sitting beside it (written by :func:`get_paddocks` only after
|
|
5
|
+
the GeoPackage write completes). The marker is a *sibling file* rather
|
|
6
|
+
than an inside-the-store file because GeoPackages are single-file
|
|
7
|
+
SQLite databases, not directories — so the zarr-style "marker inside
|
|
8
|
+
the dir" convention doesn't apply.
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from os.path import exists
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def check_if_valid_paddocks_exists(paddocks_path: str) -> bool:
|
|
16
|
+
"""Return True iff a successfully-written paddocks GeoPackage is on disk."""
|
|
17
|
+
return exists(paddocks_path) and exists(f'{paddocks_path}._SUCCESS')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_no_file_no_marker():
|
|
21
|
+
import tempfile
|
|
22
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
23
|
+
return check_if_valid_paddocks_exists(f'{tmpdir}/missing.gpkg') is False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_file_without_marker():
|
|
27
|
+
import tempfile
|
|
28
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
29
|
+
gpkg = f'{tmpdir}/p.gpkg'
|
|
30
|
+
open(gpkg, 'w').close()
|
|
31
|
+
return check_if_valid_paddocks_exists(gpkg) is False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_file_with_marker():
|
|
35
|
+
import tempfile
|
|
36
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
37
|
+
gpkg = f'{tmpdir}/p.gpkg'
|
|
38
|
+
open(gpkg, 'w').close()
|
|
39
|
+
open(f'{gpkg}._SUCCESS', 'w').close()
|
|
40
|
+
return check_if_valid_paddocks_exists(gpkg) is True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_marker_without_file():
|
|
44
|
+
import tempfile
|
|
45
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
46
|
+
open(f'{tmpdir}/p.gpkg._SUCCESS', 'w').close()
|
|
47
|
+
return check_if_valid_paddocks_exists(f'{tmpdir}/p.gpkg') is False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test():
|
|
51
|
+
return all([
|
|
52
|
+
test_no_file_no_marker(),
|
|
53
|
+
test_file_without_marker(),
|
|
54
|
+
test_file_with_marker(),
|
|
55
|
+
test_marker_without_file(),
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == '__main__':
|
|
60
|
+
print(test())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Cache-validity check for the preseg GeoTIFF.
|
|
2
|
+
|
|
3
|
+
A valid cache requires both the ``.tif`` file and a ``.tif._SUCCESS``
|
|
4
|
+
sentinel sitting beside it (written by :func:`presegment` only after the
|
|
5
|
+
TIFF write completes). The marker is a *sibling file* because GeoTIFFs
|
|
6
|
+
are single files, not directory stores.
|
|
7
|
+
|
|
8
|
+
Mirrors :func:`PaddockTS.PaddockSegmentation.check_if_valid_paddocks_exists`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from os.path import exists
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_if_valid_preseg_exists(preseg_path: str) -> bool:
|
|
15
|
+
"""Return True iff a successfully-written preseg TIFF is on disk."""
|
|
16
|
+
return exists(preseg_path) and exists(f'{preseg_path}._SUCCESS')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_no_file_no_marker():
|
|
20
|
+
import tempfile
|
|
21
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
22
|
+
return check_if_valid_preseg_exists(f'{tmpdir}/missing.tif') is False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_file_without_marker():
|
|
26
|
+
import tempfile
|
|
27
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
28
|
+
tif = f'{tmpdir}/p.tif'
|
|
29
|
+
open(tif, 'w').close()
|
|
30
|
+
return check_if_valid_preseg_exists(tif) is False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_file_with_marker():
|
|
34
|
+
import tempfile
|
|
35
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
36
|
+
tif = f'{tmpdir}/p.tif'
|
|
37
|
+
open(tif, 'w').close()
|
|
38
|
+
open(f'{tif}._SUCCESS', 'w').close()
|
|
39
|
+
return check_if_valid_preseg_exists(tif) is True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test():
|
|
43
|
+
return all([
|
|
44
|
+
test_no_file_no_marker(),
|
|
45
|
+
test_file_without_marker(),
|
|
46
|
+
test_file_with_marker(),
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == '__main__':
|
|
51
|
+
print(test())
|