easy-eo 0.4.0__tar.gz → 0.4.2__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 (55) hide show
  1. {easy_eo-0.4.0 → easy_eo-0.4.2}/PKG-INFO +1 -1
  2. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/__init__.py +1 -1
  3. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/analysis/indices.py +39 -103
  4. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/analysis/stats.py +107 -63
  5. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/common.py +73 -52
  6. easy_eo-0.4.2/eeo/core/adapters/rasterio.py +251 -0
  7. easy_eo-0.4.2/eeo/core/blockwise.py +408 -0
  8. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/core/core.py +3 -3
  9. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/core/core.pyi +13 -0
  10. easy_eo-0.4.2/eeo/core/streaming.py +304 -0
  11. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/io/_sentinel2.py +51 -0
  12. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/io/products.py +8 -0
  13. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/io/stac.py +79 -0
  14. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/ops/algebra.py +74 -140
  15. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/ops/merge.py +7 -13
  16. easy_eo-0.4.2/eeo/preprocessing/__init__.py +59 -0
  17. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/preprocessing/clip.py +11 -12
  18. easy_eo-0.4.2/eeo/preprocessing/masking.py +382 -0
  19. easy_eo-0.4.2/eeo/preprocessing/normalize.py +205 -0
  20. easy_eo-0.4.2/eeo/preprocessing/quality.py +956 -0
  21. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/preprocessing/reproject.py +17 -17
  22. {easy_eo-0.4.0 → easy_eo-0.4.2}/pyproject.toml +2 -1
  23. easy_eo-0.4.0/eeo/core/adapters/rasterio.py +0 -147
  24. easy_eo-0.4.0/eeo/preprocessing/__init__.py +0 -16
  25. easy_eo-0.4.0/eeo/preprocessing/normalize.py +0 -161
  26. {easy_eo-0.4.0 → easy_eo-0.4.2}/.gitignore +0 -0
  27. {easy_eo-0.4.0 → easy_eo-0.4.2}/LICENSE +0 -0
  28. {easy_eo-0.4.0 → easy_eo-0.4.2}/README.md +0 -0
  29. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/_optional.py +0 -0
  30. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/_show_versions.py +0 -0
  31. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/analysis/__init__.py +0 -0
  32. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/core/__init__.py +0 -0
  33. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/core/adapters/__init__.py +0 -0
  34. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/core/adapters/base.py +0 -0
  35. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/core/adapters/numpy.py +0 -0
  36. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/core/decorators.py +0 -0
  37. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/core/exceptions.py +0 -0
  38. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/core/loader.py +0 -0
  39. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/core/plugins.py +0 -0
  40. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/core/types.py +0 -0
  41. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/datasets/__init__.py +0 -0
  42. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/datasets/_cache.py +0 -0
  43. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/datasets/_registry.py +0 -0
  44. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/datasets/_samples.py +0 -0
  45. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/io/__init__.py +0 -0
  46. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/io/_archive.py +0 -0
  47. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/io/_bands.py +0 -0
  48. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/io/_landsat.py +0 -0
  49. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/io/_stacking.py +0 -0
  50. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/io/xarray.py +0 -0
  51. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/ops/__init__.py +0 -0
  52. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/preprocessing/resample.py +0 -0
  53. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/py.typed +0 -0
  54. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/viz/__init__.py +0 -0
  55. {easy_eo-0.4.0 → easy_eo-0.4.2}/eeo/viz/plot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: easy-eo
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: A lightweight Earth Observation utilities library for chainable raster operations
5
5
  Project-URL: Homepage, https://github.com/tommy-burns/easy-eo
6
6
  Project-URL: Repository, https://github.com/tommy-burns/easy-eo
@@ -40,4 +40,4 @@ __all__ = [
40
40
  "MissingDependencyError",
41
41
  ]
42
42
 
43
- __version__ = "0.4.0"
43
+ __version__ = "0.4.2"
@@ -27,12 +27,8 @@ here: any normalized-difference index can be expressed with it directly.
27
27
  import numpy as np
28
28
  import rasterio as rio
29
29
 
30
- from eeo.common import (
31
- align_raster_to_target,
32
- apply_nodata_contract,
33
- get_nodata,
34
- resolve_band_index,
35
- )
30
+ from eeo.common import align_raster_to_target, resolve_band_index
31
+ from eeo.core.blockwise import BlockSource, apply_blockwise
36
32
  from eeo.core.core import EEORasterDataset
37
33
  from eeo.core.decorators import eeo_raster_op
38
34
  from eeo.core.exceptions import AlignmentError, ValidationError
@@ -57,15 +53,15 @@ def _safe_ratio(numerator, denominator):
57
53
  return np.where(denominator != 0, quotient, np.float32(0)).astype(rio.float32)
58
54
 
59
55
 
60
- def _resolve_band(ds, spec, *, auto_align, method):
61
- """Resolve a band spec to ``(band_float32, band_raw, nodata)``.
56
+ def _resolve_band(ds, spec, *, auto_align, method) -> BlockSource:
57
+ """Resolve a band spec to the :class:`BlockSource` that reads it.
62
58
 
63
59
  ``spec`` is a 1-based ``int`` band index into ``ds``, the ``str`` name of
64
60
  one of ``ds``'s bands, or a separate ``EEORasterDataset`` (its first band
65
61
  is used, aligned onto ``ds``'s grid when ``auto_align`` is True). Index and
66
62
  name specs both go through the shared resolver, so a string is always a
67
- name and never an index. ``band_raw`` keeps its source dtype so the nodata
68
- mask compares against the declared sentinel exactly.
63
+ name and never an index. The source reads the band in its own dtype, so
64
+ the nodata mask compares against the declared sentinel exactly.
69
65
  """
70
66
  if isinstance(spec, EEORasterDataset):
71
67
  other = spec
@@ -76,56 +72,35 @@ def _resolve_band(ds, spec, *, auto_align, method):
76
72
  raise AlignmentError(
77
73
  _ALIGN_MISMATCH.format(other=other.get_shape(), ds=ds.get_shape())
78
74
  )
79
- raw = other.get_band(1)
80
- nodata = get_nodata(other)
81
- elif isinstance(spec, (int, str)) and not isinstance(spec, bool):
82
- raw = ds.get_band(resolve_band_index(ds, spec))
83
- nodata = get_nodata(ds)
84
- else:
85
- raise ValidationError(
86
- "band must be an EEORasterDataset, a 1-based int band index, or a "
87
- f"band name; got {type(spec).__name__}"
88
- )
89
- return raw.astype(rio.float32), raw, nodata
75
+ return BlockSource.from_dataset(other, band=1)
76
+ if isinstance(spec, (int, str)) and not isinstance(spec, bool):
77
+ return BlockSource.from_dataset(ds, band=resolve_band_index(ds, spec))
78
+ raise ValidationError(
79
+ "band must be an EEORasterDataset, a 1-based int band index, or a "
80
+ f"band name; got {type(spec).__name__}"
81
+ )
90
82
 
91
83
 
92
84
  def _compute_index(ds, band_specs, formula, *, auto_align, method, name=None):
93
- """Resolve band specs, apply ``formula``, and package the float32 result.
85
+ """Resolve band specs, apply ``formula`` block-wise, and package the result.
94
86
 
95
87
  ``band_specs`` is the ordered list of band specs; the first is the primary
96
- band. ``formula`` maps the list of float32 band arrays to a 2D result. The
97
- result is masked per the nodata contract (contagious across every band) and
98
- returned as a single-band float32 ``EEORasterDataset`` whose band carries
99
- ``name`` (unnamed when ``name`` is None).
88
+ band. ``formula`` maps the list of float32 band arrays to a 2D result, and
89
+ is called once per window rather than once over the whole scene, so an
90
+ index never holds more than a block of each band in memory. The result is
91
+ masked per the nodata contract (contagious across every band) and returned
92
+ as a single-band float32 ``EEORasterDataset`` whose band carries ``name``
93
+ (unnamed when ``name`` is None).
100
94
  """
101
95
  ds = ds.to_rasterio()
102
- resolved = [
103
- _resolve_band(ds, spec, auto_align=auto_align, method=method) for spec in band_specs
104
- ]
105
- floats = [band for band, _raw, _nodata in resolved]
106
- operands = [(raw, nodata) for _band, raw, nodata in resolved]
107
- primary_nodata = resolved[0][2]
96
+ sources = [_resolve_band(ds, spec, auto_align=auto_align, method=method) for spec in band_specs]
108
97
 
109
- result = formula(floats)
98
+ def compute(*blocks):
99
+ # The formulas are written against float32 bands; the engine masks
100
+ # against these raw blocks, which keep their source dtype.
101
+ return formula([block.astype(rio.float32) for block in blocks])
110
102
 
111
- index, out_nodata = apply_nodata_contract(
112
- result, operands, fractional=True, ds_nodata=primary_nodata
113
- )
114
-
115
- data = index[np.newaxis, ...] if index.ndim == 2 else index
116
- meta = ds.get_metadata().copy()
117
- meta.update(
118
- driver="GTiff",
119
- dtype="float32",
120
- nodata=out_nodata,
121
- height=data.shape[-2],
122
- width=data.shape[-1],
123
- count=data.shape[0],
124
- )
125
- memfile = rio.io.MemoryFile()
126
- out_ds = memfile.open(**meta)
127
- out_ds.write(data)
128
- result = EEORasterDataset.from_rasterio(out_ds)
103
+ result = apply_blockwise(ds, compute, sources=sources, fractional=True)
129
104
  if name is not None:
130
105
  result.set_band_name(1, name)
131
106
  return result
@@ -189,7 +164,6 @@ def normalized_difference(
189
164
 
190
165
  Notes
191
166
  -----
192
- Reads both rasters fully into memory rather than streaming block-wise.
193
167
  Nodata pixels are masked before the ratio; separately, a zero denominator
194
168
  (``ds + other == 0``) is guarded by setting those pixels to 0.
195
169
 
@@ -205,38 +179,19 @@ def normalized_difference(
205
179
  else:
206
180
  raise AlignmentError(_ALIGN_MISMATCH.format(other=other.get_shape(), ds=ds.get_shape()))
207
181
 
208
- ds_nodata = get_nodata(ds)
209
- other_nodata = get_nodata(other)
210
- a_raw = ds.read()
211
- b_raw = other.read()
212
- a = a_raw.astype(rio.float32)
213
- b = b_raw.astype(rio.float32)
182
+ def difference(a_block, b_block):
183
+ # Masking is the engine's job and happens after this returns, so a
184
+ # masked pixel is NaN regardless of the ratio computed for it here.
185
+ a = a_block.astype(rio.float32)
186
+ b = b_block.astype(rio.float32)
187
+ return _safe_ratio(a - b, a + b)
214
188
 
215
- nd = _safe_ratio(a - b, a + b)
216
-
217
- # Mask nodata last so a masked pixel is NaN regardless of its ratio.
218
- nd, out_nodata = apply_nodata_contract(
219
- nd,
220
- [(a_raw, ds_nodata), (b_raw, other_nodata)],
189
+ result = apply_blockwise(
190
+ ds,
191
+ difference,
192
+ sources=[BlockSource.from_dataset(ds), BlockSource.from_dataset(other)],
221
193
  fractional=True,
222
- ds_nodata=ds_nodata,
223
194
  )
224
-
225
- meta = ds.get_metadata().copy()
226
- meta.update(
227
- driver="GTiff",
228
- dtype="float32",
229
- nodata=out_nodata,
230
- height=nd.shape[-2],
231
- width=nd.shape[-1],
232
- count=nd.shape[0],
233
- )
234
-
235
- memfile = rio.io.MemoryFile()
236
- out_ds = memfile.open(**meta)
237
- out_ds.write(nd)
238
-
239
- result = EEORasterDataset.from_rasterio(out_ds)
240
195
  if name is not None:
241
196
  if result.get_count() != 1:
242
197
  raise ValidationError(
@@ -309,11 +264,6 @@ def ndvi(
309
264
  If a band argument is not an ``EEORasterDataset``, an int index, or a
310
265
  band name, or if a band name is unknown or matches more than one band.
311
266
 
312
- Notes
313
- -----
314
- Reads the required bands fully into memory rather than streaming
315
- block-wise.
316
-
317
267
  Examples
318
268
  --------
319
269
  >>> ndvi = nir_ds.ndvi(red_ds) # separate band rasters
@@ -393,9 +343,7 @@ def ndwi(
393
343
 
394
344
  Notes
395
345
  -----
396
- Reads the required bands fully into memory rather than streaming
397
- block-wise. This is McFeeters' water NDWI; the moisture variant is
398
- :meth:`ndmi`.
346
+ This is McFeeters' water NDWI; the moisture variant is :meth:`ndmi`.
399
347
 
400
348
  Examples
401
349
  --------
@@ -475,8 +423,7 @@ def ndmi(
475
423
 
476
424
  Notes
477
425
  -----
478
- Reads the required bands fully into memory rather than streaming
479
- block-wise. Sentinel-2's SWIR1 (B11) is 20 m; pass ``auto_align=True``
426
+ Sentinel-2's SWIR1 (B11) is 20 m; pass ``auto_align=True``
480
427
  (the default) to resample it onto a 10 m NIR grid.
481
428
 
482
429
  Examples
@@ -558,8 +505,7 @@ def ndbi(
558
505
 
559
506
  Notes
560
507
  -----
561
- Reads the required bands fully into memory rather than streaming
562
- block-wise. Sentinel-2's SWIR1 (B11) is 20 m; pass ``auto_align=True``
508
+ Sentinel-2's SWIR1 (B11) is 20 m; pass ``auto_align=True``
563
509
  (the default) to resample it onto a 10 m NIR grid.
564
510
 
565
511
  Examples
@@ -648,11 +594,6 @@ def evi(
648
594
  If a band argument is not an ``EEORasterDataset``, an int index, or a
649
595
  band name, or if a band name is unknown or matches more than one band.
650
596
 
651
- Notes
652
- -----
653
- Reads the required bands fully into memory rather than streaming
654
- block-wise.
655
-
656
597
  Examples
657
598
  --------
658
599
  >>> evi = nir_ds.evi(red_ds, blue_ds) # separate band rasters
@@ -740,11 +681,6 @@ def savi(
740
681
  If a band argument is not an ``EEORasterDataset``, an int index, or a
741
682
  band name, or if a band name is unknown or matches more than one band.
742
683
 
743
- Notes
744
- -----
745
- Reads the required bands fully into memory rather than streaming
746
- block-wise.
747
-
748
684
  Examples
749
685
  --------
750
686
  >>> savi = nir_ds.savi(red_ds, soil_factor=0.5) # separate band rasters
@@ -1,14 +1,71 @@
1
- """Per-pixel statistics and coordinate sampling."""
1
+ """Per-pixel statistics and coordinate sampling.
2
+
3
+ Each of these reports a value *and* where it occurs, which is why they stream
4
+ rather than reduce: the value comes from a streaming reduction over the band
5
+ (:mod:`eeo.core.streaming`), and a second streaming pass finds the pixel that
6
+ carries it. Memory is bounded by the block in both, so a statistic can be taken
7
+ over a scene far larger than memory.
8
+
9
+ Ties are broken exactly as ``numpy.nanargmin`` breaks them — first in
10
+ row-major order — regardless of how the raster happens to be cut into blocks,
11
+ so a position never depends on the block shape.
12
+ """
2
13
 
3
14
  import numpy as np
15
+ from rasterio.windows import Window
4
16
 
5
- from eeo.common import get_nodata, mask_nodata
17
+ from eeo.common import get_nodata, resolve_band_index
6
18
  from eeo.core.core import EEORasterDataset
7
19
  from eeo.core.decorators import eeo_raster_op
8
20
  from eeo.core.exceptions import ValidationError
21
+ from eeo.core.streaming import stream_windows, valid_mean_std, valid_percentiles
9
22
 
10
23
  Coordinate = tuple[float, float] | list[float]
11
24
 
25
+ _NO_VALID_PIXELS = "band {band} has no valid pixels, so {what} is undefined"
26
+
27
+
28
+ def _locate(ds, band_idx, score, what):
29
+ """Stream the band and return ``(value, row, col)`` minimising ``score``.
30
+
31
+ ``score`` maps a block of pixels to a block of comparison values; the
32
+ smallest wins. Because a block is a contiguous rectangle, block-local
33
+ row-major order agrees with global row-major order inside it, so taking
34
+ the first minimum per block and then breaking ties on ``(row, col)``
35
+ reproduces ``numpy.nanargmin`` over the whole band for any block shape.
36
+
37
+ Returns the *pixel's* value, not its score, so a caller searching for the
38
+ pixel nearest a target gets the measurement rather than the distance.
39
+ """
40
+ band = resolve_band_index(ds, band_idx)
41
+ best = None
42
+ for window, block, valid in stream_windows(ds, band):
43
+ if not valid.any():
44
+ continue
45
+ # Score in float64, never in the block's own dtype: negating a uint8
46
+ # or uint16 block to turn a maximum into a minimum wraps instead of
47
+ # changing sign, which silently returns the *smallest* pixel whenever
48
+ # the band contains a zero.
49
+ scored = np.where(valid, score(block.astype(np.float64)), np.inf)
50
+ flat = int(np.argmin(scored))
51
+ local_row, local_col = np.unravel_index(flat, scored.shape)
52
+ row = int(window.row_off) + int(local_row)
53
+ col = int(window.col_off) + int(local_col)
54
+ candidate = (float(scored[local_row, local_col]), row, col)
55
+ if best is None or candidate < best[:3]:
56
+ best = (*candidate, block[local_row, local_col])
57
+ if best is None:
58
+ raise ValidationError(_NO_VALID_PIXELS.format(band=band_idx, what=what))
59
+ _score, row, col, value = best
60
+ return value, row, col
61
+
62
+
63
+ def _position(ds, row, col, as_pixel_coordinate):
64
+ """Render a pixel location as ``(row, col)`` or as world coordinates."""
65
+ if as_pixel_coordinate:
66
+ return (row, col)
67
+ return ds.get_transform() * (col, row)
68
+
12
69
 
13
70
  @eeo_raster_op
14
71
  def extract_value_at_coordinate(
@@ -45,8 +102,9 @@ def extract_value_at_coordinate(
45
102
 
46
103
  Notes
47
104
  -----
48
- Reads the selected band into memory. Coordinates are ``(x, y)`` in CRS
49
- units, distinct from the ``(row, col)`` pixel indexing used elsewhere.
105
+ Reads a single pixel, not the band: sampling one location never costs the
106
+ scene. Coordinates are ``(x, y)`` in CRS units, distinct from the
107
+ ``(row, col)`` pixel indexing used elsewhere.
50
108
 
51
109
  Examples
52
110
  --------
@@ -67,7 +125,10 @@ def extract_value_at_coordinate(
67
125
  row, col = backend.index(x, y)
68
126
  row, col = int(row), int(col)
69
127
 
70
- value = ds.get_band(band_idx)[row, col]
128
+ # A 1x1 window, so sampling a point in a 10980x10980 scene reads one
129
+ # pixel rather than the 241 MB band around it.
130
+ window = Window(col, row, 1, 1)
131
+ value = ds.read(resolve_band_index(ds, band_idx), window=window)[0, 0]
71
132
 
72
133
  # Report nodata as NaN rather than the raw pixel value, so a fill value that
73
134
  # sits near real measurements is never mistaken for one.
@@ -118,26 +179,22 @@ def get_maximum_pixel(
118
179
 
119
180
  Notes
120
181
  -----
121
- Reads the band into memory. Nodata pixels are masked to NaN and ignored.
182
+ Streams the band block by block, so memory is bounded by the block rather
183
+ than the band. Nodata pixels are excluded.
122
184
 
123
185
  Examples
124
186
  --------
125
187
  >>> peak = ds.get_maximum_pixel()
126
188
  >>> peak["value"], peak["position"]
127
189
  """
128
- band = mask_nodata(ds, ds.get_band(band_idx))
129
-
130
- # get max value
131
- value = float(np.nanmax(band))
132
- row, col = np.unravel_index(np.nanargmax(band), band.shape)
133
-
134
- if return_position_as_pixel_coordinate:
135
- position = (row, col)
136
- else:
137
- transform = ds.get_transform()
138
- position = transform * (col, row)
139
-
140
- return {"value": value, "position": position}
190
+ ds = ds.to_rasterio()
191
+ # Negating turns the search for a maximum into the same minimisation the
192
+ # other three do, tie-breaking included.
193
+ value, row, col = _locate(ds, band_idx, lambda block: -block, "a maximum")
194
+ return {
195
+ "value": float(value),
196
+ "position": _position(ds, row, col, return_position_as_pixel_coordinate),
197
+ }
141
198
 
142
199
 
143
200
  @eeo_raster_op
@@ -178,26 +235,21 @@ def get_minimum_pixel(
178
235
 
179
236
  Notes
180
237
  -----
181
- Reads the band into memory. Nodata pixels are masked to NaN and ignored.
238
+ Streams the band block by block in two passes — one to measure, one to
239
+ locate — so memory is bounded by the block rather than the band. Nodata
240
+ pixels are excluded from both.
182
241
 
183
242
  Examples
184
243
  --------
185
244
  >>> low = ds.get_minimum_pixel()
186
245
  >>> low["value"], low["position"]
187
246
  """
188
- band = mask_nodata(ds, ds.get_band(band_idx))
189
-
190
- # get min value
191
- value = float(np.nanmin(band))
192
- row, col = np.unravel_index(np.nanargmin(band), band.shape)
193
-
194
- if return_position_as_pixel_coordinate:
195
- position = (row, col)
196
- else:
197
- transform = ds.get_transform()
198
- position = transform * (col, row)
199
-
200
- return {"value": value, "position": position}
247
+ ds = ds.to_rasterio()
248
+ value, row, col = _locate(ds, band_idx, lambda block: block, "a minimum")
249
+ return {
250
+ "value": float(value),
251
+ "position": _position(ds, row, col, return_position_as_pixel_coordinate),
252
+ }
201
253
 
202
254
 
203
255
  @eeo_raster_op
@@ -239,27 +291,22 @@ def get_mean_pixel(
239
291
 
240
292
  Notes
241
293
  -----
242
- Reads the band into memory. Nodata pixels are masked to NaN and ignored.
294
+ Streams the band block by block in two passes — one to measure, one to
295
+ locate — so memory is bounded by the block rather than the band. Nodata
296
+ pixels are excluded from both.
243
297
 
244
298
  Examples
245
299
  --------
246
300
  >>> centre = ds.get_mean_pixel()
247
301
  >>> centre["value"], centre["position"]
248
302
  """
249
- band = mask_nodata(ds, ds.get_band(band_idx))
250
-
251
- mean_value = float(np.nanmean(band))
252
- diff = np.abs(band - mean_value)
253
-
254
- row, col = np.unravel_index(np.nanargmin(diff), diff.shape)
255
-
256
- if return_position_as_pixel_coordinate:
257
- position = (row, col)
258
- else:
259
- transform = ds.get_transform()
260
- position = transform * (col, row)
261
-
262
- return {"value": mean_value, "position": position}
303
+ ds = ds.to_rasterio()
304
+ mean_value, _std = valid_mean_std(ds, resolve_band_index(ds, band_idx))
305
+ _value, row, col = _locate(ds, band_idx, lambda block: np.abs(block - mean_value), "a mean")
306
+ return {
307
+ "value": mean_value,
308
+ "position": _position(ds, row, col, return_position_as_pixel_coordinate),
309
+ }
263
310
 
264
311
 
265
312
  @eeo_raster_op
@@ -304,24 +351,21 @@ def get_percentile_pixel(
304
351
 
305
352
  Notes
306
353
  -----
307
- Reads the band into memory. Nodata pixels are masked to NaN and ignored.
354
+ Streams the band block by block in two passes — one to measure, one to
355
+ locate — so memory is bounded by the block rather than the band. Nodata
356
+ pixels are excluded from both.
308
357
 
309
358
  Examples
310
359
  --------
311
360
  >>> p95 = ds.get_percentile_pixel(95)
312
361
  >>> p95["value"], p95["position"]
313
362
  """
314
- band = mask_nodata(ds, ds.get_band(band_idx))
315
-
316
- perc_value = float(np.nanpercentile(band, percentile))
317
- diff = np.abs(band - perc_value)
318
-
319
- row, col = np.unravel_index(np.nanargmin(diff), diff.shape)
320
-
321
- if return_position_as_pixel_coordinate:
322
- position = (row, col)
323
- else:
324
- transform = ds.get_transform()
325
- position = transform * (col, row)
326
-
327
- return {"value": perc_value, "position": position}
363
+ ds = ds.to_rasterio()
364
+ (perc_value,) = valid_percentiles(ds, (percentile,), resolve_band_index(ds, band_idx))
365
+ _value, row, col = _locate(
366
+ ds, band_idx, lambda block: np.abs(block - perc_value), "a percentile"
367
+ )
368
+ return {
369
+ "value": perc_value,
370
+ "position": _position(ds, row, col, return_position_as_pixel_coordinate),
371
+ }
@@ -19,12 +19,11 @@ def is_rasterio_backed(ds: EEORasterDataset) -> bool:
19
19
  """Return True if ``ds`` is backed by the rasterio adapter.
20
20
 
21
21
  Detection is based on the adapter type, not the class of the backend
22
- object. A rasterio-backed dataset's ``backend`` may be a
23
- ``rasterio.io.DatasetReader`` (opened from a file) or a
24
- ``rasterio.io.DatasetWriter`` (produced in memory by an operation, e.g.
25
- the result of any algebra op or ``to_rasterio()``); both are valid
26
- rasterio backends. Checking ``isinstance(backend, DatasetReader)`` misses
27
- the writer case and wrongly rejects genuinely rasterio-backed datasets.
22
+ object. A rasterio-backed dataset's ``backend`` is usually a
23
+ ``rasterio.io.DatasetReader`` — from a file, or an in-memory result, which
24
+ the library reopens read-only — but a ``rasterio.io.DatasetWriter`` wrapped
25
+ with ``EEORasterDataset.from_rasterio`` is equally valid. Checking
26
+ ``isinstance(backend, DatasetReader)`` would wrongly reject that case.
28
27
 
29
28
  Parameters
30
29
  ----------
@@ -164,7 +163,7 @@ def _declared_nodata_mask(array, nodata):
164
163
  return array == nodata
165
164
 
166
165
 
167
- def _output_dtype(result, *, fractional: bool):
166
+ def resolve_output_dtype(result, *, fractional: bool):
168
167
  """Return the output dtype for a pixel-wise result per the dtype policy.
169
168
 
170
169
  Fractional-result ops are always float32. Exact arithmetic keeps the
@@ -178,63 +177,85 @@ def _output_dtype(result, *, fractional: bool):
178
177
  return dtype
179
178
 
180
179
 
181
- def apply_nodata_contract(result, operands, *, fractional: bool, ds_nodata):
182
- """Apply the library nodata & dtype contract to a pixel-wise result.
180
+ def _combined_nodata_mask(operands):
181
+ """Boolean mask of pixels that are nodata in any operand, or None if none are.
182
+
183
+ Nodata is contagious: the masks of every operand that declares one are
184
+ OR-ed together. Returns None when no operand declares a nodata value,
185
+ which is the same condition as the contract producing no output nodata.
186
+ """
187
+ combined = None
188
+ for array, nodata in operands:
189
+ mask = _declared_nodata_mask(array, nodata)
190
+ if mask is None:
191
+ continue
192
+ combined = mask if combined is None else (combined | mask)
193
+ return combined
183
194
 
184
- Masks the pixels that are nodata in any operand (nodata is contagious),
185
- casts to the contract's output dtype, and reports the nodata value to
186
- record in the output metadata.
195
+
196
+ def resolve_output_nodata(operand_nodatas, *, out_dtype, ds_nodata):
197
+ """Return the nodata value a pixel-wise result should record, or None.
198
+
199
+ Decided from declared nodata values and the output dtype alone — no pixel
200
+ data — so a block-wise operation can fix one nodata value for the whole
201
+ output before reading the first block.
187
202
 
188
203
  Parameters
189
204
  ----------
190
- result : array-like
191
- Values computed over every pixel. Nodata pixels are overwritten here,
192
- so computing them first (then masking) is equivalent to masking first
193
- for element-wise operations.
194
- operands : list of tuple
195
- ``(array, nodata)`` for each raster operand; scalar operands are
196
- omitted since they carry no nodata.
197
- fractional : bool
198
- True for operations whose result is inherently fractional (float32
199
- output regardless of input dtype).
205
+ operand_nodatas : iterable
206
+ Declared nodata value (or None) of each raster operand.
207
+ out_dtype : numpy.dtype
208
+ Dtype the output will be written in.
200
209
  ds_nodata : int, float, or None
201
210
  The primary operand's declared nodata, used as the sentinel for
202
211
  integer outputs.
203
212
 
204
213
  Returns
205
214
  -------
206
- tuple
207
- ``(final_array, out_nodata)`` — the masked, correctly typed array and
208
- the nodata value for the output metadata (``float('nan')`` for
209
- floating outputs, the integer sentinel for integer outputs, or None
210
- when no operand declares nodata).
215
+ float or int or None
216
+ ``float('nan')`` for floating outputs, the integer sentinel for
217
+ integer outputs, or None when no operand declares nodata.
211
218
  """
212
- out_dtype = _output_dtype(result, fractional=fractional)
213
- is_float_out = np.issubdtype(out_dtype, np.floating)
219
+ declared = [nodata for nodata in operand_nodatas if nodata is not None]
220
+ if not declared:
221
+ # No operand declared nodata: every pixel is valid, nothing to record.
222
+ return None
223
+ if np.issubdtype(out_dtype, np.floating):
224
+ return float("nan")
225
+ sentinel = ds_nodata if ds_nodata is not None else declared[0]
226
+ return np.array(sentinel, dtype=out_dtype).item()
214
227
 
215
- combined = None
216
- declared = []
217
- for array, nodata in operands:
218
- if nodata is not None:
219
- declared.append(nodata)
220
- mask = _declared_nodata_mask(array, nodata)
221
- if mask is None:
222
- continue
223
- combined = mask if combined is None else (combined | mask)
224
228
 
225
- result = result.astype(out_dtype)
229
+ def apply_nodata_mask(result, operands, *, out_dtype, out_nodata):
230
+ """Cast a pixel-wise result to ``out_dtype`` and mark its nodata pixels.
231
+
232
+ The dtype and nodata value are supplied rather than derived, so every
233
+ block of a block-wise run lands in the same dtype and uses the same
234
+ marker even when a block happens to contain no nodata pixels at all.
226
235
 
236
+ Parameters
237
+ ----------
238
+ result : array-like
239
+ Values computed over every pixel of the block.
240
+ operands : list of tuple
241
+ ``(array, nodata)`` for each raster operand, over the same pixels as
242
+ ``result``; scalar operands are omitted since they carry no nodata.
243
+ out_dtype : numpy.dtype
244
+ Dtype to cast the result to.
245
+ out_nodata : float, int, or None
246
+ Marker written into nodata pixels, from :func:`resolve_output_nodata`.
247
+ None means no operand declares nodata and nothing is masked.
248
+
249
+ Returns
250
+ -------
251
+ array-like
252
+ The masked result in ``out_dtype``.
253
+ """
254
+ result = result.astype(out_dtype)
255
+ if out_nodata is None:
256
+ return result
257
+ combined = _combined_nodata_mask(operands)
227
258
  if combined is None:
228
- # No operand declared nodata: nothing to mask, no output nodata.
229
- return result, None
230
-
231
- if is_float_out:
232
- marker = np.array(np.nan, dtype=out_dtype)
233
- out_nodata: float = float("nan")
234
- else:
235
- sentinel = ds_nodata if ds_nodata is not None else declared[0]
236
- marker = np.array(sentinel, dtype=out_dtype)
237
- out_nodata = marker.item()
238
-
239
- final = np.where(combined, marker, result).astype(out_dtype)
240
- return final, out_nodata
259
+ return result
260
+ marker = np.array(out_nodata, dtype=out_dtype)
261
+ return np.where(combined, marker, result).astype(out_dtype)