easy-eo 0.4.2__tar.gz → 0.6.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.
Files changed (64) hide show
  1. easy_eo-0.6.0/PKG-INFO +459 -0
  2. easy_eo-0.6.0/README.md +392 -0
  3. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/__init__.py +4 -1
  4. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/_optional.py +8 -5
  5. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/_show_versions.py +4 -2
  6. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/common.py +67 -0
  7. easy_eo-0.6.0/eeo/core/adapters/__init__.py +15 -0
  8. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/adapters/base.py +4 -3
  9. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/adapters/rasterio.py +3 -1
  10. easy_eo-0.6.0/eeo/core/adapters/xarray.py +410 -0
  11. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/blockwise.py +15 -4
  12. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/core.py +64 -17
  13. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/core.pyi +3 -2
  14. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/loader.py +34 -10
  15. easy_eo-0.6.0/eeo/core/remote.py +122 -0
  16. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/types.py +4 -0
  17. easy_eo-0.6.0/eeo/io/_dedupe.py +237 -0
  18. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/io/_stacking.py +1 -11
  19. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/io/stac.py +124 -0
  20. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/io/xarray.py +69 -0
  21. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/ops/merge.py +9 -16
  22. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/preprocessing/clip.py +4 -14
  23. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/preprocessing/reproject.py +3 -8
  24. easy_eo-0.6.0/eeo/timeseries/__init__.py +13 -0
  25. easy_eo-0.6.0/eeo/timeseries/binning.py +395 -0
  26. easy_eo-0.6.0/eeo/timeseries/core.py +2343 -0
  27. easy_eo-0.6.0/eeo/timeseries/extract.py +157 -0
  28. easy_eo-0.6.0/eeo/timeseries/reducers.py +343 -0
  29. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/viz/__init__.py +3 -0
  30. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/viz/plot.py +16 -6
  31. easy_eo-0.6.0/eeo/viz/timeseries.py +278 -0
  32. {easy_eo-0.4.2 → easy_eo-0.6.0}/pyproject.toml +13 -1
  33. easy_eo-0.4.2/PKG-INFO +0 -371
  34. easy_eo-0.4.2/README.md +0 -309
  35. easy_eo-0.4.2/eeo/core/adapters/__init__.py +0 -11
  36. {easy_eo-0.4.2 → easy_eo-0.6.0}/.gitignore +0 -0
  37. {easy_eo-0.4.2 → easy_eo-0.6.0}/LICENSE +0 -0
  38. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/analysis/__init__.py +0 -0
  39. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/analysis/indices.py +0 -0
  40. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/analysis/stats.py +0 -0
  41. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/__init__.py +0 -0
  42. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/adapters/numpy.py +0 -0
  43. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/decorators.py +0 -0
  44. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/exceptions.py +0 -0
  45. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/plugins.py +0 -0
  46. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/core/streaming.py +0 -0
  47. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/datasets/__init__.py +0 -0
  48. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/datasets/_cache.py +0 -0
  49. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/datasets/_registry.py +0 -0
  50. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/datasets/_samples.py +0 -0
  51. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/io/__init__.py +0 -0
  52. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/io/_archive.py +0 -0
  53. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/io/_bands.py +0 -0
  54. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/io/_landsat.py +0 -0
  55. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/io/_sentinel2.py +0 -0
  56. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/io/products.py +0 -0
  57. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/ops/__init__.py +0 -0
  58. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/ops/algebra.py +0 -0
  59. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/preprocessing/__init__.py +0 -0
  60. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/preprocessing/masking.py +0 -0
  61. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/preprocessing/normalize.py +0 -0
  62. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/preprocessing/quality.py +0 -0
  63. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/preprocessing/resample.py +0 -0
  64. {easy_eo-0.4.2 → easy_eo-0.6.0}/eeo/py.typed +0 -0
easy_eo-0.6.0/PKG-INFO ADDED
@@ -0,0 +1,459 @@
1
+ Metadata-Version: 2.5
2
+ Name: easy-eo
3
+ Version: 0.6.0
4
+ Summary: A lightweight Earth Observation utilities library for chainable raster operations
5
+ Project-URL: Homepage, https://github.com/tommy-burns/easy-eo
6
+ Project-URL: Repository, https://github.com/tommy-burns/easy-eo
7
+ Project-URL: Documentation, https://easy-eo.readthedocs.io
8
+ Project-URL: Issues, https://github.com/tommy-burns/easy-eo/issues
9
+ Author: Thomas Burns Botchwey
10
+ License: MIT License
11
+
12
+ Copyright (c) 2025 Thomas Burns Botchwey
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: earth observation,gis,raster,remote sensing
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Intended Audience :: Science/Research
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Programming Language :: Python :: 3.13
41
+ Classifier: Programming Language :: Python :: 3.14
42
+ Classifier: Topic :: Scientific/Engineering :: GIS
43
+ Classifier: Typing :: Typed
44
+ Requires-Python: >=3.10
45
+ Requires-Dist: geopandas<2,>=1.1
46
+ Requires-Dist: matplotlib>=3.8
47
+ Requires-Dist: numpy<3,>=1.26
48
+ Requires-Dist: pandas>=2.0
49
+ Requires-Dist: rasterio<2,>=1.4
50
+ Provides-Extra: dev
51
+ Requires-Dist: mypy>=1.10; extra == 'dev'
52
+ Requires-Dist: pre-commit>=3.7; extra == 'dev'
53
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
54
+ Requires-Dist: pytest>=8.0; extra == 'dev'
55
+ Requires-Dist: ruff>=0.6; extra == 'dev'
56
+ Provides-Extra: lazy
57
+ Requires-Dist: dask[array]>=2024.8; extra == 'lazy'
58
+ Requires-Dist: rioxarray<1,>=0.17; extra == 'lazy'
59
+ Requires-Dist: xarray>=2024.7; extra == 'lazy'
60
+ Provides-Extra: stac
61
+ Requires-Dist: planetary-computer<2,>=1.0; extra == 'stac'
62
+ Requires-Dist: pystac-client<1,>=0.8; extra == 'stac'
63
+ Provides-Extra: xarray
64
+ Requires-Dist: rioxarray<1,>=0.17; extra == 'xarray'
65
+ Requires-Dist: xarray>=2024.7; extra == 'xarray'
66
+ Description-Content-Type: text/markdown
67
+
68
+ # Easy-EO
69
+
70
+ <p align="center">
71
+ <img src="https://raw.githubusercontent.com/Tommy-Burns/easy-eo/main/.github/assets/eeo_logo.png" alt="Easy-EO logo" width="200">
72
+ </p>
73
+
74
+ | | |
75
+ | :-- | :-- |
76
+ | **Install** | [![PyPI][pypi-badge]][pypi] [![conda-forge][conda-badge]][conda] [![Python versions][pyversions-badge]][pypi] [![Platforms][platforms-badge]][ci] |
77
+ | **Build & quality** | [![CI][ci-badge]][ci] [![Latest deps][latest-deps-badge]][latest-deps] [![Coverage][codecov-badge]][codecov] [![Ruff][ruff-badge]][ruff] [![Checked with mypy][mypy-badge]][mypy] |
78
+ | **Security** | [![CodeQL][codeql-badge]][codeql] [![OpenSSF Scorecard][scorecard-badge]][scorecard] |
79
+ | **Project** | [![Documentation][docs-badge]][docs] [![License: MIT][license-badge]][license] [![DOI][doi-badge]][doi] |
80
+
81
+ [pypi]: https://pypi.org/project/easy-eo/
82
+ [pypi-badge]: https://img.shields.io/pypi/v/easy-eo.svg
83
+ [conda]: https://anaconda.org/conda-forge/easy-eo
84
+ [conda-badge]: https://img.shields.io/conda/vn/conda-forge/easy-eo.svg
85
+ [pyversions-badge]: https://img.shields.io/pypi/pyversions/easy-eo.svg
86
+ [platforms-badge]: https://img.shields.io/badge/platform-linux%20%7C%20macOS%20%7C%20windows-lightgrey.svg
87
+ [ci]: https://github.com/Tommy-Burns/easy-eo/actions/workflows/ci.yml
88
+ [ci-badge]: https://github.com/Tommy-Burns/easy-eo/actions/workflows/ci.yml/badge.svg
89
+ [latest-deps]: https://github.com/Tommy-Burns/easy-eo/actions/workflows/latest-deps.yml
90
+ [latest-deps-badge]: https://github.com/Tommy-Burns/easy-eo/actions/workflows/latest-deps.yml/badge.svg
91
+ [codecov]: https://codecov.io/gh/Tommy-Burns/easy-eo
92
+ [codecov-badge]: https://codecov.io/gh/Tommy-Burns/easy-eo/branch/main/graph/badge.svg
93
+ [ruff]: https://github.com/astral-sh/ruff
94
+ [ruff-badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
95
+ [mypy]: https://mypy-lang.org/
96
+ [mypy-badge]: https://img.shields.io/badge/mypy-checked-2a6db2.svg
97
+ [codeql]: https://github.com/Tommy-Burns/easy-eo/actions/workflows/codeql.yml
98
+ [codeql-badge]: https://github.com/Tommy-Burns/easy-eo/actions/workflows/codeql.yml/badge.svg
99
+ [scorecard]: https://scorecard.dev/viewer/?uri=github.com/Tommy-Burns/easy-eo
100
+ [scorecard-badge]: https://api.scorecard.dev/projects/github.com/Tommy-Burns/easy-eo/badge
101
+ [docs]: https://easy-eo.readthedocs.io/en/latest/?badge=latest
102
+ [docs-badge]: https://readthedocs.org/projects/easy-eo/badge/?version=latest
103
+ [license]: https://github.com/Tommy-Burns/easy-eo/blob/main/LICENSE
104
+ [license-badge]: https://img.shields.io/github/license/Tommy-Burns/easy-eo
105
+ [doi]: https://doi.org/10.5281/zenodo.21967655
106
+ [doi-badge]: https://zenodo.org/badge/DOI/10.5281/zenodo.21967655.svg
107
+
108
+
109
+ Easy-EO is a lightweight, extensible Python library for raster-based Earth
110
+ Observation (EO) analysis. It covers searching and loading imagery, cloud
111
+ masking, band algebra and spectral indices, time series and visualization, and
112
+ each step is a few readable lines of Python.
113
+
114
+ ## From satellite archive to NDVI map
115
+
116
+ ```python
117
+ import eeo
118
+
119
+ results = eeo.stac_search(
120
+ collection="sentinel-2-l2a",
121
+ bbox=(11.0, 46.5, 11.2, 46.7), # area of interest, WGS 84 lon/lat
122
+ datetime="2023-06-01/2023-08-31",
123
+ cloud_cover=20,
124
+ limit=1,
125
+ )
126
+ scene = results[0].load(assets=["B04", "B08"]) # reads only the area of interest
127
+ ndvi = scene.ndvi(red="B04", nir="B08")
128
+ ndvi.plot_raster()
129
+ ```
130
+
131
+ That is the whole workflow: no scene downloads and no GDAL wrangling. The search
132
+ queries [Microsoft Planetary Computer](https://planetarycomputer.microsoft.com/)
133
+ by default, and any other STAC catalog works through `catalog="<catalog URL>"`.
134
+ The load streams only the window covering your bounding box over HTTP, which
135
+ here is 14 MB out of a 240 MB Sentinel-2 tile and takes a few seconds.
136
+
137
+ `stac_search` needs the STAC extra: `pip install "easy-eo[stac]"`, or the
138
+ [conda equivalent](#installation). To start offline instead, jump to the
139
+ [hosted sample dataset](#quick-example), which needs no network after the first
140
+ call.
141
+
142
+ ---
143
+
144
+ ## Features
145
+
146
+ | | What you get | Guide |
147
+ | --- | --- | --- |
148
+ | **Catalog search** | `stac_search()` over any STAC catalog, by bounding box or by a polygon; loading reads only your area of interest over HTTP; `deduplicate()` drops reprocessed copies of the same acquisition | [Satellite data](https://easy-eo.readthedocs.io/en/latest/user_guide/loading_satellite_data.html) |
149
+ | **Downloaded products** | `load_sentinel2()` and `load_landsat()` read a Sentinel-2 L2A `.SAFE` or a Landsat Collection 2 product as you downloaded it, still zipped or tarred, with bands chosen by name and the acquisition time recorded | [Downloaded scenes](https://easy-eo.readthedocs.io/en/latest/user_guide/loading_downloaded_scenes.html) |
150
+ | **Cloud masking** | `mask_clouds()` sets cloud, cloud shadow and cirrus to nodata from the Sentinel-2 scene classification or the Landsat `QA_PIXEL` band, including medium-confidence cloud; `clear_fraction()` reports how much of a scene is left | [Masking clouds](https://easy-eo.readthedocs.io/en/latest/user_guide/masking_clouds.html) |
151
+ | **Time series** | `time_series()` builds a series from a search, a folder of GeoTIFFs or scenes you loaded; `map()` applies any operation to every date; median/mean/min/max, cloud-free `composite()`, monthly or weekly `resample_time()`; `extract_at()` returns a pixel's trajectory as a pandas DataFrame | [Time series](https://easy-eo.readthedocs.io/en/latest/user_guide/time_series.html) |
152
+ | **Spectral indices** | `ndvi`, `ndwi`, `ndmi`, `ndbi`, `evi`, `savi`, plus `normalized_difference` for anything else, all chainable and float32 | [Spectral indices](https://easy-eo.readthedocs.io/en/latest/user_guide/spectral_indices.html) |
153
+ | **Band algebra** | `add`, `subtract`, `multiply`, `divide`, `power`, `sqrt`, `log`, `absolute`, and the matching operators | [Operations](https://easy-eo.readthedocs.io/en/latest/user_guide/ops.html) |
154
+ | **Preprocessing** | Clip to a bounding box or a vector, resample, reproject, mosaic, stack, normalize (min-max, percentile, z-score) | [Preprocessing](https://easy-eo.readthedocs.io/en/latest/user_guide/preprocessing.html) |
155
+ | **Large rasters** | Algebra, indices and normalization run a block at a time; `apply_blockwise()` streams a computation of your own to disk; `load_raster(path, chunks=...)` opens a raster lazily with dask, including a COG over HTTP | [Large rasters](https://easy-eo.readthedocs.io/en/latest/user_guide/large_rasters.html) |
156
+ | **Named bands and metadata** | Address any band as `"red"` or `"nir"` wherever a 1-based index works; names survive a GeoTIFF round-trip; a timestamp and free-form `attrs` follow the data through every operation; `describe()` summarises a raster without reading its pixels | [Naming bands](https://easy-eo.readthedocs.io/en/latest/user_guide/band_names.html) · [The dataset](https://easy-eo.readthedocs.io/en/latest/user_guide/core_dataset.html) |
157
+ | **Statistics** | Per-band min/max/mean/percentile with their pixel locations, and value extraction at a coordinate | [Statistical locations](https://easy-eo.readthedocs.io/en/latest/user_guide/statistical_locations.html) |
158
+ | **Visualization** | Single bands, RGB composites, histograms, map-plus-histogram views, and for a series a trajectory plot and a filmstrip of dates, all read at display resolution | [Visualization](https://easy-eo.readthedocs.io/en/latest/user_guide/visualization.html) |
159
+ | **Predictable nodata and dtype** | One written-down contract every operation follows: mask before compute, nodata stays contagious, fractional results are float32 | [Nodata and dtype](https://easy-eo.readthedocs.io/en/latest/user_guide/nodata_and_dtype.html) |
160
+ | **Ecosystem interop** | `to_xarray()` / `from_xarray()` in both directions, and `to_xarray()` on a whole series; NumPy, Rasterio and xarray backends behind one interface | [xarray interop](https://easy-eo.readthedocs.io/en/latest/user_guide/xarray_interop.html) · [Backends](https://easy-eo.readthedocs.io/en/latest/backends.html) |
161
+ | **Typed and tested** | Ships `py.typed`, 2,800+ tests, 99% coverage, checked on Python 3.10-3.14 across Linux, macOS and Windows | [Contributing](https://github.com/Tommy-Burns/easy-eo/blob/main/CONTRIBUTING.md) |
162
+
163
+ ---
164
+
165
+ ## Before and after
166
+
167
+ One ordinary task: clip a 4-band scene to an area of interest held in a vector
168
+ file, compute NDVI, and save it as a GeoTIFF. Both versions below run as
169
+ written, against the same
170
+ [hosted sample dataset](https://easy-eo.readthedocs.io/en/latest/user_guide/sample_data.html)
171
+ (a 1024x1024 Sentinel-2 subset and a boundary polygon), so you can paste either
172
+ one and watch it work.
173
+
174
+ Here it is in raw Rasterio, GeoPandas and NumPy, with Easy-EO not installed at
175
+ all:
176
+
177
+ ```python
178
+ import geopandas as gpd
179
+ import numpy as np
180
+ import rasterio
181
+ from rasterio.mask import mask
182
+
183
+ BASE = "https://github.com/Tommy-Burns/easy-eo/releases/download/sample-data-v1/"
184
+
185
+ with rasterio.open(BASE + "sentinel2_small_cog.tif") as src:
186
+ aoi = gpd.read_file(BASE + "roi.gpkg").to_crs(src.crs)
187
+ clipped, transform = mask(src, aoi.geometry.values, crop=True)
188
+ bands = {name: i for i, name in enumerate(src.descriptions)}
189
+ nodata = src.nodata
190
+ profile = src.profile
191
+
192
+ red = clipped[bands["red"]].astype("float32")
193
+ nir = clipped[bands["nir"]].astype("float32")
194
+
195
+ valid = (red != nodata) & (nir != nodata)
196
+ total = nir + red
197
+ ndvi = np.where(valid & (total != 0), (nir - red) / np.where(total == 0, 1, total), 0.0)
198
+ ndvi = np.where(valid, ndvi, np.nan).astype("float32")
199
+
200
+ profile.update(
201
+ count=1, dtype="float32", nodata=np.nan,
202
+ height=ndvi.shape[0], width=ndvi.shape[1], transform=transform,
203
+ )
204
+ with rasterio.open("ndvi.tif", "w", **profile) as dst:
205
+ dst.write(ndvi, 1)
206
+ ```
207
+
208
+ And in Easy-EO, where `load_sample_dataset()` fetches the same two files and
209
+ caches them:
210
+
211
+ ```python
212
+ import eeo
213
+ from eeo.datasets import load_sample_dataset
214
+
215
+ sd = load_sample_dataset()
216
+
217
+ (
218
+ eeo.load_raster(path=sd.sentinel2_cog_stacked)
219
+ .clip_raster_with_vector(vector_file=sd.boundary)
220
+ .ndvi(red="red", nir="nir")
221
+ .save_raster(path="ndvi.tif")
222
+ )
223
+ ```
224
+
225
+ Both blocks produce **byte-identical output**: the same shape, CRS, transform
226
+ and nodata, and every pixel value, including the roughly one quarter of the
227
+ image that the clip masks away.
228
+
229
+ So the point is not the line count. Rasterio makes you take four decisions by
230
+ hand, and each one is a chance to be quietly wrong: reprojecting the AOI into
231
+ the raster's CRS (the sample boundary is lon/lat, the scene is UTM), mapping
232
+ band names to indices, masking nodata before the arithmetic, and rebuilding the
233
+ output profile. Drop just the mask and NDVI comes out as `0.0` across the
234
+ clipped-away quarter of the image, a value that looks like bare ground in your
235
+ statistics and your plot rather than like missing data.
236
+
237
+ Easy-EO applies those same rules for you, consistently, on every operation.
238
+ They are written down in the
239
+ [nodata and dtype contract](https://easy-eo.readthedocs.io/en/latest/user_guide/nodata_and_dtype.html),
240
+ and each one is backed by tests.
241
+
242
+ ---
243
+
244
+ ## A season, not a scene
245
+
246
+ One cloud-free NDVI map per month over a growing season, and how one field
247
+ changed through it:
248
+
249
+ ```python
250
+ import eeo
251
+
252
+ results = eeo.stac_search(
253
+ collection="sentinel-2-l2a",
254
+ bbox=(5.60, 52.05, 5.65, 52.085),
255
+ datetime="2023-04-01/2023-09-30",
256
+ cloud_cover=50,
257
+ )
258
+ ts = eeo.time_series(source=results.deduplicate(), assets=["B04", "B08", "SCL"])
259
+
260
+ monthly = ts.resample_time(freq="MS").composite() # one cloud-free raster per month
261
+ ndvi = monthly.map(eeo.ndvi, red="B04", nir="B08", name="ndvi")
262
+
263
+ ndvi.plot_filmstrip(cmap="RdYlGn") # a small map per month
264
+ ndvi.extract_at(coordinates=(5.625, 52.0675), crs="EPSG:4326") # a pandas DataFrame
265
+ ```
266
+
267
+ `composite()` masks every date with its own scene classification band before
268
+ taking the median, so a pixel is built only from the dates it was clear, and
269
+ the `SCL` band is dropped from the result. A month that was overcast
270
+ throughout comes back as nodata rather than as a cloud-coloured value.
271
+
272
+ `deduplicate()` keeps one copy of each acquisition. Catalogs publish
273
+ reprocessed scenes alongside the originals, and without it the repeated dates
274
+ would count twice in the median.
275
+
276
+ ---
277
+
278
+ ## Products as you downloaded them
279
+
280
+ A Sentinel-2 product still zipped, and a Landsat product still tarred, read by
281
+ band name:
282
+
283
+ ```python
284
+ import eeo
285
+
286
+ s2 = eeo.load_sentinel2(
287
+ path="S2A_MSIL2A_20260814T100041_N0512_R122_T33TVM_20260814T151116.SAFE.zip",
288
+ bands=["red", "nir", "scl"],
289
+ )
290
+ l9 = eeo.load_landsat(
291
+ path="LC09_L2SP_192027_20260815_20260816_02_T1.tar",
292
+ bands=["red", "nir08", "qa_pixel"],
293
+ )
294
+
295
+ clear = l9.mask_clouds() # cloud, cloud shadow and cirrus set to nodata
296
+ clear.clear_fraction() # share of the scene still usable
297
+ ndvi = clear.ndvi(red="red", nir="nir08")
298
+ ```
299
+
300
+ Each loader resolves the band names to the right files for that mission and
301
+ product, and records the acquisition time and product metadata. Pass
302
+ `bbox=(minx, miny, maxx, maxy)` in lon/lat to read only part of the scene.
303
+
304
+ ---
305
+
306
+ ## Installation
307
+
308
+ Python 3.10 or newer, from either package manager:
309
+
310
+ ```bash
311
+ pip install easy-eo
312
+ ```
313
+
314
+ ```bash
315
+ conda install -c conda-forge easy-eo
316
+ ```
317
+
318
+ That is everything you need for the core: raster I/O, the product loaders,
319
+ cloud masking, algebra, indices, preprocessing, time series and plotting.
320
+ Three heavier integrations are kept separate, so you only install them if you
321
+ use them:
322
+
323
+ | Adds | pip | conda |
324
+ | --- | --- | --- |
325
+ | `stac_search()` and loading scenes from STAC catalogs | `pip install "easy-eo[stac]"` | `conda install -c conda-forge easy-eo pystac-client planetary-computer` |
326
+ | `to_xarray()` / `from_xarray()` | `pip install "easy-eo[xarray]"` | `conda install -c conda-forge easy-eo xarray rioxarray` |
327
+ | Opening a raster lazily (`load_raster(path, chunks=...)`), including over HTTP | `pip install "easy-eo[lazy]"` | `conda install -c conda-forge easy-eo xarray rioxarray dask-core` |
328
+
329
+ pip extras compose: `pip install "easy-eo[stac,xarray]"` installs both. conda
330
+ has no concept of extras, so `conda install "easy-eo[stac]"` is not a valid
331
+ command; the same packages are installed by name instead, as above.
332
+
333
+ **Use one package manager, not both.** If Easy-EO came from conda, install the
334
+ extras from conda too. conda's solver knows nothing about pip-installed files,
335
+ so a later `conda install` or `conda update` can overwrite them or leave a
336
+ second copy of a shared dependency in the environment. Every extra dependency
337
+ is on conda-forge, so there is no reason to mix.
338
+
339
+ Without an extra installed, the features that need it raise a
340
+ `MissingDependencyError` that tells you exactly what to install. Nothing fails
341
+ silently at import time.
342
+
343
+ ## Quick example
344
+
345
+ Offline after the first call, using the hosted sample dataset:
346
+
347
+ ```python
348
+ import eeo
349
+ from eeo.datasets import load_sample_dataset
350
+
351
+ sd = load_sample_dataset()
352
+ scene = eeo.load_raster(path=sd.sentinel2_cog_stacked) # red, green, blue, nir bands
353
+ scene.describe() # metadata only, no pixels read
354
+
355
+ ndvi = scene.ndvi(red="red", nir="nir", name="NDVI")
356
+ ndvi.plot_raster(cmap="RdYlGn", colorbar=True)
357
+ ```
358
+
359
+ Operations chain, and a multi-line chain is wrapped in parentheses:
360
+
361
+ ```python
362
+ result = (
363
+ scene.clip_raster_with_vector(vector_file=sd.boundary)
364
+ .ndvi(red="red", nir="nir")
365
+ .normalize_percentile(lower_percentile=2, upper_percentile=98)
366
+ )
367
+ ```
368
+
369
+ ## Tutorials
370
+
371
+ Seventeen runnable notebooks live in
372
+ [`examples/`](https://github.com/Tommy-Burns/easy-eo/blob/main/examples/README.md),
373
+ from first install through to complete analyses (flood mapping, drought stress,
374
+ land cover, terrain, and a season of cloud-free composites). Each one opens in
375
+ Colab with no local setup, because the first cell installs Easy-EO when it
376
+ detects Colab:
377
+
378
+ | | |
379
+ | --- | --- |
380
+ | [Quickstart: NDVI](https://github.com/Tommy-Burns/easy-eo/blob/main/examples/00_getting_started/02_quickstart_ndvi.ipynb): open a scene, compute an index, plot it | [![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/Tommy-Burns/easy-eo/blob/main/examples/00_getting_started/02_quickstart_ndvi.ipynb) |
381
+ | [Search and load from STAC](https://github.com/Tommy-Burns/easy-eo/blob/main/examples/02_data_access/02_stac_search_and_load.ipynb): find real scenes, read them over HTTP | [![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/Tommy-Burns/easy-eo/blob/main/examples/02_data_access/02_stac_search_and_load.ipynb) |
382
+ | [Cloud-free composites and trends](https://github.com/Tommy-Burns/easy-eo/blob/main/examples/04_timeseries/01_cloud_free_composite_and_trends.ipynb): a season of Sentinel-2, masked, composited and followed through time | [![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/Tommy-Burns/easy-eo/blob/main/examples/04_timeseries/01_cloud_free_composite_and_trends.ipynb) |
383
+ | [Flood mapping with NDWI](https://github.com/Tommy-Burns/easy-eo/blob/main/examples/03_real_world/01_flood_mapping_ndwi.ipynb): Pakistan 2022, before/after, area affected | [![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/Tommy-Burns/easy-eo/blob/main/examples/03_real_world/01_flood_mapping_ndwi.ipynb) |
384
+
385
+ The full index, including what each notebook covers, is in
386
+ [`examples/README.md`](https://github.com/Tommy-Burns/easy-eo/blob/main/examples/README.md)
387
+ and on the [tutorials page](https://easy-eo.readthedocs.io/en/latest/tutorials.html)
388
+ of the documentation.
389
+
390
+ ---
391
+
392
+ ## Gallery
393
+
394
+ Every image below comes straight out of an Easy-EO plotting call on the sample
395
+ dataset, with the library's own defaults and no touch-ups. Regenerate them all
396
+ with `python scripts/build_gallery.py`.
397
+
398
+ | | |
399
+ | --- | --- |
400
+ | <img src="https://raw.githubusercontent.com/Tommy-Burns/easy-eo/main/.github/assets/gallery/composite_true_colour.jpg" alt="True colour composite of a Sentinel-2 scene"> | <img src="https://raw.githubusercontent.com/Tommy-Burns/easy-eo/main/.github/assets/gallery/composite_false_colour.jpg" alt="False colour composite, vegetation in red"> |
401
+ | `scene.plot_composite(bands=["red", "green", "blue"])` | `scene.plot_composite(bands=["nir", "red", "green"])` |
402
+ | <img src="https://raw.githubusercontent.com/Tommy-Burns/easy-eo/main/.github/assets/gallery/index_ndvi.jpg" alt="NDVI map on a red-yellow-green colour scale"> | <img src="https://raw.githubusercontent.com/Tommy-Burns/easy-eo/main/.github/assets/gallery/dem_terrain.jpg" alt="Copernicus DEM elevation map"> |
403
+ | `scene.ndvi(red="red", nir="nir", name="NDVI").plot_raster(cmap="RdYlGn")` | `dem.plot_raster(cmap="Spectral_r")`, the same call on a DEM |
404
+ | <img src="https://raw.githubusercontent.com/Tommy-Burns/easy-eo/main/.github/assets/gallery/histogram_bands.png" alt="Value distribution of each of the four bands"> | <img src="https://raw.githubusercontent.com/Tommy-Burns/easy-eo/main/.github/assets/gallery/clip_ndvi_histogram.png" alt="NDVI clipped to a hexagonal boundary beside its histogram"> |
405
+ | `scene.plot_histogram()`, every band at once | `clipped.plot_raster_with_histogram(cmap="RdYlGn")` |
406
+
407
+ Bands are addressed by name throughout (`"red"`, `"nir"`) because the sample
408
+ carries band descriptions; a 1-based index works anywhere a name does.
409
+
410
+ ## Backends
411
+
412
+ One dataset type, with three backends underneath it:
413
+
414
+ | Backend | What it is for |
415
+ | --- | --- |
416
+ | Rasterio | Files and COGs GDAL can open, local or over HTTP, from `load_raster()` |
417
+ | NumPy | In-memory arrays with a CRS and transform attached, from `load_array()` and the product loaders |
418
+ | xarray + dask | Lazy, chunked reading of rasters larger than memory, from `load_raster(path, chunks=...)` (the `lazy` extra) |
419
+
420
+ Every operation, statistic and plot works on the Rasterio and lazy backends and
421
+ returns the same answer on both. On the NumPy backend, clipping, reprojecting,
422
+ mosaicking and stacking need a `.to_rasterio()` first, and the error says so.
423
+
424
+ See [Backends](https://easy-eo.readthedocs.io/en/latest/backends.html) for how
425
+ they fit together.
426
+
427
+ ## Documentation
428
+
429
+ Full documentation, with user guides and the API reference:
430
+ [easy-eo.readthedocs.io](https://easy-eo.readthedocs.io/en/latest/index.html)
431
+
432
+ ## Citing Easy-EO
433
+
434
+ If Easy-EO contributes to published work, please cite it. The DOI
435
+ [10.5281/zenodo.21967655](https://doi.org/10.5281/zenodo.21967655) always
436
+ resolves to the latest release, and
437
+ [`CITATION.cff`](https://github.com/Tommy-Burns/easy-eo/blob/main/CITATION.cff)
438
+ drives GitHub's "Cite this repository" button. The
439
+ [citation page](https://easy-eo.readthedocs.io/en/latest/citation.html) has a
440
+ BibTeX entry, and a separate one for the sample dataset.
441
+
442
+ ## Project status
443
+
444
+ Active development. The API is stabilising but may change before v1.0, and
445
+ every breaking change is listed under a **Breaking** heading in the
446
+ [changelog](https://github.com/Tommy-Burns/easy-eo/blob/main/CHANGELOG.md).
447
+
448
+ ## Contributing
449
+
450
+ Contributions are welcome: bug reports, feature requests and documentation
451
+ improvements as much as code. Please open an issue or pull request on GitHub,
452
+ and see [`CONTRIBUTING.md`](https://github.com/Tommy-Burns/easy-eo/blob/main/CONTRIBUTING.md)
453
+ for how to set up a development environment and run the checks. Report
454
+ security issues privately, as described in
455
+ [`SECURITY.md`](https://github.com/Tommy-Burns/easy-eo/blob/main/SECURITY.md).
456
+
457
+ ## License
458
+
459
+ MIT License © 2025 Thomas Burns Botchwey