pysentinel2 0.1.0__tar.gz
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.
- pysentinel2-0.1.0/LICENSE +21 -0
- pysentinel2-0.1.0/PKG-INFO +225 -0
- pysentinel2-0.1.0/README.md +186 -0
- pysentinel2-0.1.0/pyproject.toml +61 -0
- pysentinel2-0.1.0/pysentinel2/__init__.py +11 -0
- pysentinel2-0.1.0/pysentinel2/clean_sentinel2.py +73 -0
- pysentinel2-0.1.0/pysentinel2/cube.py +985 -0
- pysentinel2-0.1.0/pysentinel2/derive.py +135 -0
- pysentinel2-0.1.0/pysentinel2/download_sentinel2.py +157 -0
- pysentinel2-0.1.0/pysentinel2/grid.py +247 -0
- pysentinel2-0.1.0/pysentinel2/index.py +270 -0
- pysentinel2-0.1.0/pysentinel2/paths.py +61 -0
- pysentinel2-0.1.0/pysentinel2/sentinel2.py +52 -0
- pysentinel2-0.1.0/pysentinel2.egg-info/PKG-INFO +225 -0
- pysentinel2-0.1.0/pysentinel2.egg-info/SOURCES.txt +17 -0
- pysentinel2-0.1.0/pysentinel2.egg-info/dependency_links.txt +1 -0
- pysentinel2-0.1.0/pysentinel2.egg-info/requires.txt +15 -0
- pysentinel2-0.1.0/pysentinel2.egg-info/top_level.txt +1 -0
- pysentinel2-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Borevitz Lab, Australian National University, and contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pysentinel2
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cached Sentinel-2 ARD cube downloads (STAC to Zarr) with cloud masking
|
|
5
|
+
Author: Borevitz Lab, Australian National University
|
|
6
|
+
Author-email: Yasar Adeel Ansari <u6737670@anu.edu.au>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/thestochasticman/pysentinel2
|
|
9
|
+
Project-URL: Repository, https://github.com/thestochasticman/pysentinel2
|
|
10
|
+
Project-URL: Issues, https://github.com/thestochasticman/pysentinel2/issues
|
|
11
|
+
Keywords: sentinel-2,remote-sensing,stac,zarr,geospatial,earth-observation
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: attrs
|
|
24
|
+
Requires-Dist: typing_extensions
|
|
25
|
+
Requires-Dist: numpy
|
|
26
|
+
Requires-Dist: xarray
|
|
27
|
+
Requires-Dist: zarr
|
|
28
|
+
Requires-Dist: dask
|
|
29
|
+
Requires-Dist: pyproj
|
|
30
|
+
Requires-Dist: affine
|
|
31
|
+
Requires-Dist: odc-stac
|
|
32
|
+
Requires-Dist: odc-geo
|
|
33
|
+
Requires-Dist: pystac
|
|
34
|
+
Requires-Dist: pystac-client
|
|
35
|
+
Requires-Dist: urllib3
|
|
36
|
+
Requires-Dist: opencv-python
|
|
37
|
+
Requires-Dist: rioxarray
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# pysentinel2
|
|
41
|
+
|
|
42
|
+
A **local Sentinel-2 datacube that fills itself on demand**. Every pixel
|
|
43
|
+
this machine ever downloads lands in one sparse, pixel-indexed store —
|
|
44
|
+
so nothing is ever downloaded twice: overlapping areas, extended date
|
|
45
|
+
ranges and repeat runs all reuse the same chunks. Part of the
|
|
46
|
+
[Borevitz Lab](https://borevitzlab.anu.edu.au/) ecosystem; the default
|
|
47
|
+
source is [Digital Earth Australia](https://explorer.dea.ga.gov.au/)'s
|
|
48
|
+
ARD collections (`ga_s2am_ard_3` / `ga_s2bm_ard_3`) via STAC.
|
|
49
|
+
|
|
50
|
+
Full documentation — architecture, grid geometry, storage, cleaning,
|
|
51
|
+
indices, robustness — is in [`docs/`](docs/README.md), with flowcharts
|
|
52
|
+
and figures generated from a real store.
|
|
53
|
+
|
|
54
|
+

|
|
55
|
+
*Contents of the store for a 2 × 2 km example window. Clear, cloudy and
|
|
56
|
+
off-swath days are all stored raw and classified at read time.*
|
|
57
|
+
|
|
58
|
+
## How it works
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
{data_root}/sentinel2_cube/
|
|
62
|
+
├── index.db # SQLite: coverage rects · seen scenes · past searches
|
|
63
|
+
└── cube.zarr/
|
|
64
|
+
├── 2024-01-03/ # one group per solar day
|
|
65
|
+
│ ├── nbart_red # arrays on a fixed EPSG:6933 10 m global grid
|
|
66
|
+
│ └── ... # sparse: only written 256×256-px chunks exist on disk
|
|
67
|
+
└── 2024-01-08/ ...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- Any bbox maps deterministically to a pixel window on the fixed grid.
|
|
71
|
+
`Cube.get_ds(bbox, start, end)` subtracts each day's recorded coverage
|
|
72
|
+
rectangles from that window and downloads **only the missing pixels** —
|
|
73
|
+
coverage accounting is pixel-exact, so small farms pay no chunk padding
|
|
74
|
+
(256×256-px chunks remain the *storage* unit inside the Zarr arrays).
|
|
75
|
+
- STAC results are cached (full item JSON) in the index, so re-reads and
|
|
76
|
+
re-fills of known regions work without re-searching. Cloud-cover
|
|
77
|
+
filtering happens at read time from the index — relaxing the threshold
|
|
78
|
+
later needs no re-search.
|
|
79
|
+
- Only raw bands (incl. fmask) are stored. `get_ds(..., clean=True)` applies
|
|
80
|
+
cloud masking **on read** — there is no second "clean" copy on disk,
|
|
81
|
+
roughly halving storage versus a raw+clean layout. See
|
|
82
|
+
[Cleaning & masking](#cleaning--masking) for exactly what the mask does.
|
|
83
|
+
- Spectral indices — NDVI, CFI, NIRv, NDTI, CAI — are on-read
|
|
84
|
+
derivatives too: `get_ds(..., indices=('NDVI', 'NIRv'))` computes them
|
|
85
|
+
from cloud-masked reflectance and stores nothing.
|
|
86
|
+
- Writes are whole-chunk and the index is transactional (SQLite/WAL): a
|
|
87
|
+
crash mid-fill just leaves cells unmarked, and the next run resumes.
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
The core API is **troi-agnostic** — just a bbox and dates, no setup:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from datetime import date
|
|
95
|
+
from pysentinel2.cube import Cube
|
|
96
|
+
|
|
97
|
+
cube = Cube()
|
|
98
|
+
bbox = [148.36265, -33.52606, 148.38265, -33.50606] # [W, S, E, N]
|
|
99
|
+
|
|
100
|
+
ds_raw = cube.get_ds(bbox, date(2024, 1, 1), date(2024, 12, 31))
|
|
101
|
+
ds = cube.get_ds(bbox, date(2024, 1, 1), date(2024, 12, 31), clean=True)
|
|
102
|
+
ds = cube.get_ds(bbox, date(2024, 1, 1), date(2024, 12, 31),
|
|
103
|
+
indices=('NDVI', 'CFI', 'NIRv', 'NDTI', 'CAI'))
|
|
104
|
+
|
|
105
|
+
cube.fill(bbox, date(2024, 1, 1), date(2024, 12, 31)) # → 0: already local
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Pipelines that speak the shared `troi.troi.Troi` (the
|
|
109
|
+
reproducibility layer — stubs, registry) use the adapters:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
ds = cube.get_ds_troi(troi) # = cube.get_ds(troi.bbox, troi.start, troi.end)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`download_sentinel2(troi)` and `clean_sentinel2(troi)` remain as thin
|
|
116
|
+
wrappers over `Cube.get_ds_troi` for pipeline compatibility.
|
|
117
|
+
|
|
118
|
+
Package design (shared across the lab's packages — no inheritance,
|
|
119
|
+
composition only):
|
|
120
|
+
|
|
121
|
+
- **`Troi`** (from `troi`) — identity: what region, what dates.
|
|
122
|
+
- **`Sentinel2`** (`pysentinel2.sentinel2`) — config: STAC URL,
|
|
123
|
+
collections, bands, CRS, cloud threshold, fmask codes.
|
|
124
|
+
- **`Paths`** (`pysentinel2.paths`) — derived locations of the store for
|
|
125
|
+
a given `Config`.
|
|
126
|
+
- **`grid`** — the fixed global grid (pure, offline-testable math).
|
|
127
|
+
- **`Index`** (`pysentinel2.index`) — the SQLite ledger.
|
|
128
|
+
- **`Cube`** (`pysentinel2.cube`) — ties them together.
|
|
129
|
+
|
|
130
|
+
## Cleaning & masking
|
|
131
|
+
|
|
132
|
+
`clean=True` (and any `indices=` request, which implies it) runs the
|
|
133
|
+
window through `pysentinel2.cube.clean_dataset`. The design principle:
|
|
134
|
+
**invalid and contaminated are different things.**
|
|
135
|
+
|
|
136
|
+
| Pixel state | fmask | Meaning | Treatment |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| Invalid | 0 (nodata) | Outside the scene footprint / never sensed | → NaN; counts *against coverage*, not against cloudiness |
|
|
139
|
+
| Clear | 1 | Usable land observation | kept |
|
|
140
|
+
| Cloud | 2 | Contaminated | → NaN (dilated) |
|
|
141
|
+
| Shadow | 3 | Contaminated | → NaN (dilated) |
|
|
142
|
+
| Snow | 4 | Surface state; corrupts vegetation statistics | → NaN by default (`mask_snow=False` to keep); never counts toward the frame gate |
|
|
143
|
+
| Water | 5 | Legitimate signal (NDWI, dams, rivers) | kept by default (`mask_water=True` to drop) |
|
|
144
|
+
|
|
145
|
+
Contaminated pixels are dilated before masking, frames are gated on the
|
|
146
|
+
two fractions independently, and every read is annotated with the
|
|
147
|
+
statistics and thresholds that produced it. Nothing is persisted —
|
|
148
|
+
different thresholds on the same window are just different reads of the
|
|
149
|
+
same raw store. Full pipeline, tunables and figures:
|
|
150
|
+
[docs/cleaning.md](docs/cleaning.md).
|
|
151
|
+
|
|
152
|
+
## Performance
|
|
153
|
+
|
|
154
|
+
Live measurements against DEA — a ~2 × 2 km AOI, 11-band ARD at 10 m
|
|
155
|
+
(one *cell* = one 256 × 256-px chunk on one solar day):
|
|
156
|
+
|
|
157
|
+
| Scenario | Downloaded | Time |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| Cold fill — 3 weeks (3 clear scenes) | 12 cells | 5.7 s |
|
|
160
|
+
| Same request again | nothing | **0.0 s** |
|
|
161
|
+
| AOI shifted 1 km (inside cached chunks) | nothing | **0.0 s** |
|
|
162
|
+
| Date range extended +1 month | 32 cells — *new days only* | 17.2 s |
|
|
163
|
+
| Read cached window (512² px × 3 days × 11 bands) | — | 0.13 s |
|
|
164
|
+
| Read cached window, cloud-masked (`clean=True`) | — | 0.23 s |
|
|
165
|
+
|
|
166
|
+
Store footprint: **13.6 MB for 11 solar days** — raw + fmask only, since
|
|
167
|
+
the clean cube is a 0.1 s on-read transform rather than a second copy.
|
|
168
|
+
|
|
169
|
+
Absolute times vary with network and DEA load. The zero rows are the
|
|
170
|
+
significant ones: those requests are resolved by index lookups alone,
|
|
171
|
+
with no network access.
|
|
172
|
+
|
|
173
|
+
Multi-year fills are batched, not per-day: all 11 bands for up to 64
|
|
174
|
+
missing days come down in one bulk load per batch (see
|
|
175
|
+
[the fill algorithm](docs/architecture.md#the-fill-algorithm)), keeping
|
|
176
|
+
the I/O threads saturated across day boundaries — a two-month cold fill
|
|
177
|
+
measured 20-26 s where a per-day loop measured 33 s on a healthy DEA
|
|
178
|
+
and 270 s on a degraded one, and the gap widens with the length of the
|
|
179
|
+
range. An earlier fmask-first screening pass was removed after
|
|
180
|
+
measurement: it skipped 8.8% of days' reflectance while paying an extra
|
|
181
|
+
request round on every day.
|
|
182
|
+
|
|
183
|
+
## Install
|
|
184
|
+
|
|
185
|
+
### pip
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
pip install git+https://github.com/thestochasticman/pysentinel2.git
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Dependencies (the `troi` core included, pulled from GitHub) are
|
|
192
|
+
declared in `pyproject.toml` and installed automatically.
|
|
193
|
+
|
|
194
|
+
### From source
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
git clone https://github.com/thestochasticman/pysentinel2.git
|
|
198
|
+
cd pysentinel2
|
|
199
|
+
pip install -e .
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The wheels for `rasterio`/`rioxarray`/`opencv` bundle their native
|
|
203
|
+
libraries on common platforms; in a conda environment the conda-forge
|
|
204
|
+
equivalents are used instead if already installed.
|
|
205
|
+
|
|
206
|
+
## Robustness notes
|
|
207
|
+
|
|
208
|
+
Hardening for DEA's public S3 + STAC quirks (cold-start 504s, stalled
|
|
209
|
+
reads, corrupt tiles) is built in — see
|
|
210
|
+
[docs/robustness.md](docs/robustness.md) and, for the underlying
|
|
211
|
+
investigations, [`diagnostics.md`](diagnostics.md).
|
|
212
|
+
|
|
213
|
+
## Test
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
# offline (pure math + synthetic store):
|
|
217
|
+
python pysentinel2/grid.py # True
|
|
218
|
+
python pysentinel2/index.py # True
|
|
219
|
+
python pysentinel2/paths.py # True
|
|
220
|
+
python pysentinel2/cube.py # True
|
|
221
|
+
|
|
222
|
+
# live (small real downloads from DEA, incl. dedup assertions):
|
|
223
|
+
python pysentinel2/download_sentinel2.py # True
|
|
224
|
+
python pysentinel2/clean_sentinel2.py # True
|
|
225
|
+
```
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# pysentinel2
|
|
2
|
+
|
|
3
|
+
A **local Sentinel-2 datacube that fills itself on demand**. Every pixel
|
|
4
|
+
this machine ever downloads lands in one sparse, pixel-indexed store —
|
|
5
|
+
so nothing is ever downloaded twice: overlapping areas, extended date
|
|
6
|
+
ranges and repeat runs all reuse the same chunks. Part of the
|
|
7
|
+
[Borevitz Lab](https://borevitzlab.anu.edu.au/) ecosystem; the default
|
|
8
|
+
source is [Digital Earth Australia](https://explorer.dea.ga.gov.au/)'s
|
|
9
|
+
ARD collections (`ga_s2am_ard_3` / `ga_s2bm_ard_3`) via STAC.
|
|
10
|
+
|
|
11
|
+
Full documentation — architecture, grid geometry, storage, cleaning,
|
|
12
|
+
indices, robustness — is in [`docs/`](docs/README.md), with flowcharts
|
|
13
|
+
and figures generated from a real store.
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
*Contents of the store for a 2 × 2 km example window. Clear, cloudy and
|
|
17
|
+
off-swath days are all stored raw and classified at read time.*
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
{data_root}/sentinel2_cube/
|
|
23
|
+
├── index.db # SQLite: coverage rects · seen scenes · past searches
|
|
24
|
+
└── cube.zarr/
|
|
25
|
+
├── 2024-01-03/ # one group per solar day
|
|
26
|
+
│ ├── nbart_red # arrays on a fixed EPSG:6933 10 m global grid
|
|
27
|
+
│ └── ... # sparse: only written 256×256-px chunks exist on disk
|
|
28
|
+
└── 2024-01-08/ ...
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- Any bbox maps deterministically to a pixel window on the fixed grid.
|
|
32
|
+
`Cube.get_ds(bbox, start, end)` subtracts each day's recorded coverage
|
|
33
|
+
rectangles from that window and downloads **only the missing pixels** —
|
|
34
|
+
coverage accounting is pixel-exact, so small farms pay no chunk padding
|
|
35
|
+
(256×256-px chunks remain the *storage* unit inside the Zarr arrays).
|
|
36
|
+
- STAC results are cached (full item JSON) in the index, so re-reads and
|
|
37
|
+
re-fills of known regions work without re-searching. Cloud-cover
|
|
38
|
+
filtering happens at read time from the index — relaxing the threshold
|
|
39
|
+
later needs no re-search.
|
|
40
|
+
- Only raw bands (incl. fmask) are stored. `get_ds(..., clean=True)` applies
|
|
41
|
+
cloud masking **on read** — there is no second "clean" copy on disk,
|
|
42
|
+
roughly halving storage versus a raw+clean layout. See
|
|
43
|
+
[Cleaning & masking](#cleaning--masking) for exactly what the mask does.
|
|
44
|
+
- Spectral indices — NDVI, CFI, NIRv, NDTI, CAI — are on-read
|
|
45
|
+
derivatives too: `get_ds(..., indices=('NDVI', 'NIRv'))` computes them
|
|
46
|
+
from cloud-masked reflectance and stores nothing.
|
|
47
|
+
- Writes are whole-chunk and the index is transactional (SQLite/WAL): a
|
|
48
|
+
crash mid-fill just leaves cells unmarked, and the next run resumes.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
The core API is **troi-agnostic** — just a bbox and dates, no setup:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from datetime import date
|
|
56
|
+
from pysentinel2.cube import Cube
|
|
57
|
+
|
|
58
|
+
cube = Cube()
|
|
59
|
+
bbox = [148.36265, -33.52606, 148.38265, -33.50606] # [W, S, E, N]
|
|
60
|
+
|
|
61
|
+
ds_raw = cube.get_ds(bbox, date(2024, 1, 1), date(2024, 12, 31))
|
|
62
|
+
ds = cube.get_ds(bbox, date(2024, 1, 1), date(2024, 12, 31), clean=True)
|
|
63
|
+
ds = cube.get_ds(bbox, date(2024, 1, 1), date(2024, 12, 31),
|
|
64
|
+
indices=('NDVI', 'CFI', 'NIRv', 'NDTI', 'CAI'))
|
|
65
|
+
|
|
66
|
+
cube.fill(bbox, date(2024, 1, 1), date(2024, 12, 31)) # → 0: already local
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Pipelines that speak the shared `troi.troi.Troi` (the
|
|
70
|
+
reproducibility layer — stubs, registry) use the adapters:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
ds = cube.get_ds_troi(troi) # = cube.get_ds(troi.bbox, troi.start, troi.end)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`download_sentinel2(troi)` and `clean_sentinel2(troi)` remain as thin
|
|
77
|
+
wrappers over `Cube.get_ds_troi` for pipeline compatibility.
|
|
78
|
+
|
|
79
|
+
Package design (shared across the lab's packages — no inheritance,
|
|
80
|
+
composition only):
|
|
81
|
+
|
|
82
|
+
- **`Troi`** (from `troi`) — identity: what region, what dates.
|
|
83
|
+
- **`Sentinel2`** (`pysentinel2.sentinel2`) — config: STAC URL,
|
|
84
|
+
collections, bands, CRS, cloud threshold, fmask codes.
|
|
85
|
+
- **`Paths`** (`pysentinel2.paths`) — derived locations of the store for
|
|
86
|
+
a given `Config`.
|
|
87
|
+
- **`grid`** — the fixed global grid (pure, offline-testable math).
|
|
88
|
+
- **`Index`** (`pysentinel2.index`) — the SQLite ledger.
|
|
89
|
+
- **`Cube`** (`pysentinel2.cube`) — ties them together.
|
|
90
|
+
|
|
91
|
+
## Cleaning & masking
|
|
92
|
+
|
|
93
|
+
`clean=True` (and any `indices=` request, which implies it) runs the
|
|
94
|
+
window through `pysentinel2.cube.clean_dataset`. The design principle:
|
|
95
|
+
**invalid and contaminated are different things.**
|
|
96
|
+
|
|
97
|
+
| Pixel state | fmask | Meaning | Treatment |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| Invalid | 0 (nodata) | Outside the scene footprint / never sensed | → NaN; counts *against coverage*, not against cloudiness |
|
|
100
|
+
| Clear | 1 | Usable land observation | kept |
|
|
101
|
+
| Cloud | 2 | Contaminated | → NaN (dilated) |
|
|
102
|
+
| Shadow | 3 | Contaminated | → NaN (dilated) |
|
|
103
|
+
| Snow | 4 | Surface state; corrupts vegetation statistics | → NaN by default (`mask_snow=False` to keep); never counts toward the frame gate |
|
|
104
|
+
| Water | 5 | Legitimate signal (NDWI, dams, rivers) | kept by default (`mask_water=True` to drop) |
|
|
105
|
+
|
|
106
|
+
Contaminated pixels are dilated before masking, frames are gated on the
|
|
107
|
+
two fractions independently, and every read is annotated with the
|
|
108
|
+
statistics and thresholds that produced it. Nothing is persisted —
|
|
109
|
+
different thresholds on the same window are just different reads of the
|
|
110
|
+
same raw store. Full pipeline, tunables and figures:
|
|
111
|
+
[docs/cleaning.md](docs/cleaning.md).
|
|
112
|
+
|
|
113
|
+
## Performance
|
|
114
|
+
|
|
115
|
+
Live measurements against DEA — a ~2 × 2 km AOI, 11-band ARD at 10 m
|
|
116
|
+
(one *cell* = one 256 × 256-px chunk on one solar day):
|
|
117
|
+
|
|
118
|
+
| Scenario | Downloaded | Time |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| Cold fill — 3 weeks (3 clear scenes) | 12 cells | 5.7 s |
|
|
121
|
+
| Same request again | nothing | **0.0 s** |
|
|
122
|
+
| AOI shifted 1 km (inside cached chunks) | nothing | **0.0 s** |
|
|
123
|
+
| Date range extended +1 month | 32 cells — *new days only* | 17.2 s |
|
|
124
|
+
| Read cached window (512² px × 3 days × 11 bands) | — | 0.13 s |
|
|
125
|
+
| Read cached window, cloud-masked (`clean=True`) | — | 0.23 s |
|
|
126
|
+
|
|
127
|
+
Store footprint: **13.6 MB for 11 solar days** — raw + fmask only, since
|
|
128
|
+
the clean cube is a 0.1 s on-read transform rather than a second copy.
|
|
129
|
+
|
|
130
|
+
Absolute times vary with network and DEA load. The zero rows are the
|
|
131
|
+
significant ones: those requests are resolved by index lookups alone,
|
|
132
|
+
with no network access.
|
|
133
|
+
|
|
134
|
+
Multi-year fills are batched, not per-day: all 11 bands for up to 64
|
|
135
|
+
missing days come down in one bulk load per batch (see
|
|
136
|
+
[the fill algorithm](docs/architecture.md#the-fill-algorithm)), keeping
|
|
137
|
+
the I/O threads saturated across day boundaries — a two-month cold fill
|
|
138
|
+
measured 20-26 s where a per-day loop measured 33 s on a healthy DEA
|
|
139
|
+
and 270 s on a degraded one, and the gap widens with the length of the
|
|
140
|
+
range. An earlier fmask-first screening pass was removed after
|
|
141
|
+
measurement: it skipped 8.8% of days' reflectance while paying an extra
|
|
142
|
+
request round on every day.
|
|
143
|
+
|
|
144
|
+
## Install
|
|
145
|
+
|
|
146
|
+
### pip
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
pip install git+https://github.com/thestochasticman/pysentinel2.git
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Dependencies (the `troi` core included, pulled from GitHub) are
|
|
153
|
+
declared in `pyproject.toml` and installed automatically.
|
|
154
|
+
|
|
155
|
+
### From source
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
git clone https://github.com/thestochasticman/pysentinel2.git
|
|
159
|
+
cd pysentinel2
|
|
160
|
+
pip install -e .
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The wheels for `rasterio`/`rioxarray`/`opencv` bundle their native
|
|
164
|
+
libraries on common platforms; in a conda environment the conda-forge
|
|
165
|
+
equivalents are used instead if already installed.
|
|
166
|
+
|
|
167
|
+
## Robustness notes
|
|
168
|
+
|
|
169
|
+
Hardening for DEA's public S3 + STAC quirks (cold-start 504s, stalled
|
|
170
|
+
reads, corrupt tiles) is built in — see
|
|
171
|
+
[docs/robustness.md](docs/robustness.md) and, for the underlying
|
|
172
|
+
investigations, [`diagnostics.md`](diagnostics.md).
|
|
173
|
+
|
|
174
|
+
## Test
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
# offline (pure math + synthetic store):
|
|
178
|
+
python pysentinel2/grid.py # True
|
|
179
|
+
python pysentinel2/index.py # True
|
|
180
|
+
python pysentinel2/paths.py # True
|
|
181
|
+
python pysentinel2/cube.py # True
|
|
182
|
+
|
|
183
|
+
# live (small real downloads from DEA, incl. dedup assertions):
|
|
184
|
+
python pysentinel2/download_sentinel2.py # True
|
|
185
|
+
python pysentinel2/clean_sentinel2.py # True
|
|
186
|
+
```
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pysentinel2"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cached Sentinel-2 ARD cube downloads (STAC to Zarr) with cloud masking"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Borevitz Lab, Australian National University" },
|
|
12
|
+
{ name = "Yasar Adeel Ansari", email = "u6737670@anu.edu.au" },
|
|
13
|
+
]
|
|
14
|
+
license = { text = "MIT" }
|
|
15
|
+
requires-python = ">=3.11"
|
|
16
|
+
keywords = [
|
|
17
|
+
"sentinel-2",
|
|
18
|
+
"remote-sensing",
|
|
19
|
+
"stac",
|
|
20
|
+
"zarr",
|
|
21
|
+
"geospatial",
|
|
22
|
+
"earth-observation",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Science/Research",
|
|
27
|
+
"License :: OSI Approved :: MIT License",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
33
|
+
]
|
|
34
|
+
# The geospatial stack (GDAL/rasterio, odc-stac, dask, zarr, …) is
|
|
35
|
+
# provided by conda via environment.yml; only the lab core is declared
|
|
36
|
+
# here so `pip install -e .` is fast and doesn't re-resolve it.
|
|
37
|
+
dependencies = [
|
|
38
|
+
"attrs",
|
|
39
|
+
"typing_extensions",
|
|
40
|
+
"numpy",
|
|
41
|
+
"xarray",
|
|
42
|
+
"zarr",
|
|
43
|
+
"dask",
|
|
44
|
+
"pyproj",
|
|
45
|
+
"affine",
|
|
46
|
+
"odc-stac",
|
|
47
|
+
"odc-geo",
|
|
48
|
+
"pystac",
|
|
49
|
+
"pystac-client",
|
|
50
|
+
"urllib3",
|
|
51
|
+
"opencv-python",
|
|
52
|
+
"rioxarray",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[project.urls]
|
|
56
|
+
Homepage = "https://github.com/thestochasticman/pysentinel2"
|
|
57
|
+
Repository = "https://github.com/thestochasticman/pysentinel2"
|
|
58
|
+
Issues = "https://github.com/thestochasticman/pysentinel2/issues"
|
|
59
|
+
|
|
60
|
+
[tool.setuptools.packages.find]
|
|
61
|
+
include = ["pysentinel2", "pysentinel2.*"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Light-weight exports only: pysentinel2.cube (and the download/clean
|
|
2
|
+
# wrappers) pull in the heavy geospatial stack (odc.stac, rioxarray, zarr),
|
|
3
|
+
# so those stay behind explicit submodule imports.
|
|
4
|
+
from pysentinel2.paths import Paths
|
|
5
|
+
from pysentinel2.sentinel2 import Sentinel2, defaultsentinel2
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
'Paths',
|
|
9
|
+
'Sentinel2',
|
|
10
|
+
'defaultsentinel2',
|
|
11
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Cloud-masked Sentinel-2 window for a troi — computed on read.
|
|
2
|
+
|
|
3
|
+
Thin compatibility wrapper over :func:`pysentinel2.cube.Cube.get_ds` with
|
|
4
|
+
``clean=True``. No "clean" copy is ever persisted: masking a window is
|
|
5
|
+
cheap, so the clean cube is a view of the raw store rather than a
|
|
6
|
+
second store.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from xarray import Dataset
|
|
10
|
+
from troi import Troi
|
|
11
|
+
from pysentinel2.sentinel2 import Sentinel2, defaultsentinel2
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def clean_sentinel2(
|
|
15
|
+
troi: Troi,
|
|
16
|
+
ds_sentinel2: Dataset | None = None,
|
|
17
|
+
sentinel2: Sentinel2 = defaultsentinel2,
|
|
18
|
+
**clean_kwargs,
|
|
19
|
+
) -> Dataset:
|
|
20
|
+
"""Produce a cloud-masked, frame-filtered Sentinel-2 window.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
troi: The :class:`troi.Troi`. Pixels come from the
|
|
24
|
+
shared cube (downloading only what's missing).
|
|
25
|
+
ds_sentinel2: Optional in-memory raw dataset (must still include
|
|
26
|
+
the fmask band); if given, it is masked directly and the cube
|
|
27
|
+
is not touched.
|
|
28
|
+
sentinel2: Config supplying the fmask band and class codes.
|
|
29
|
+
Defaults to the bundled DEA config.
|
|
30
|
+
**clean_kwargs: Forwarded to
|
|
31
|
+
:func:`pysentinel2.cube.clean_dataset` —
|
|
32
|
+
``max_cloud_fraction``, ``min_valid_fraction``, ``mask_snow``,
|
|
33
|
+
``mask_water``, ``buffer_px``.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
xarray.Dataset: The cleaned window, fmask band removed, only the
|
|
37
|
+
retained timesteps, with per-frame ``cloud_fraction`` /
|
|
38
|
+
``valid_fraction`` coordinates.
|
|
39
|
+
"""
|
|
40
|
+
from pysentinel2.cube import Cube, clean_dataset
|
|
41
|
+
if ds_sentinel2 is not None:
|
|
42
|
+
return clean_dataset(ds_sentinel2, sentinel2, **clean_kwargs)
|
|
43
|
+
cube = Cube(config=troi.config, sentinel2=sentinel2)
|
|
44
|
+
return cube.get_ds_troi(troi, clean=True, **clean_kwargs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_clean_drops_fmask_band():
|
|
48
|
+
"""The fmask band must not appear in the cleaned dataset (live)."""
|
|
49
|
+
import tempfile
|
|
50
|
+
from datetime import date
|
|
51
|
+
from troi import Config
|
|
52
|
+
|
|
53
|
+
tmpdir = tempfile.mkdtemp(prefix='pysentinel2_clean_test_')
|
|
54
|
+
cfg = Config(out_dir=tmpdir, tmp_dir=tmpdir)
|
|
55
|
+
q = Troi(
|
|
56
|
+
bbox=[148.36265, -33.52606, 148.38265, -33.50606],
|
|
57
|
+
start=date(2024, 1, 1), end=date(2024, 1, 21),
|
|
58
|
+
stub='clean_no_fmask', config=cfg,
|
|
59
|
+
)
|
|
60
|
+
ds = clean_sentinel2(q, max_cloud_fraction=0.7)
|
|
61
|
+
return defaultsentinel2.cloud_mask_band not in ds.data_vars and ds.time.size > 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test():
|
|
65
|
+
from pysentinel2.download_sentinel2 import test_internet
|
|
66
|
+
return all([
|
|
67
|
+
test_internet(None),
|
|
68
|
+
test_clean_drops_fmask_band(),
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == '__main__':
|
|
73
|
+
print(test())
|