PyGeoFetch 2.6.2.7__py3-none-any.whl → 2.6.2.9__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.
pygeofetch/__init__.py CHANGED
@@ -30,7 +30,7 @@ CLI::
30
30
  pygeofetch doctor
31
31
  """
32
32
 
33
- __version__ = "2.6.2.7.4"
33
+ __version__ = "2.6.2.9.4"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
@@ -50,22 +50,64 @@ def index() -> None:
50
50
  "--nir", required=True, type=click.Path(exists=True), help="NIR band (e.g. B08.tif)"
51
51
  )
52
52
  @click.option("--output", "-o", default=None)
53
- def ndvi_cmd(red, nir, output):
54
- """NDVI — Normalized Difference Vegetation Index. Range: -1 to +1. Vegetation > 0.3."""
53
+ @click.option(
54
+ "--chunked", is_flag=True,
55
+ help="Process in memory-safe tiles for large rasters (e.g. drone orthomosaics) that exceed available RAM or the 4GB GeoTIFF limit.",
56
+ )
57
+ @click.option("--tile-size", default=1024, show_default=True, type=int, help="Tile edge length in pixels when --chunked is set.")
58
+ def ndvi_cmd(red, nir, output, chunked, tile_size):
59
+ """NDVI — Normalized Difference Vegetation Index. Range: -1 to +1. Vegetation > 0.3.
60
+
61
+ Pass the SAME path to --red and --nir to auto-detect both bands from
62
+ one merged multi-band image (e.g. a drone orthomosaic), using its
63
+ real, embedded band descriptions.
64
+ """
55
65
  e = _engine()
56
- r = e.indices.ndvi(red=red, nir=nir, output=output)
66
+ r = e.indices.ndvi(red=red, nir=nir, output=output, chunked=chunked, tile_size=tile_size)
57
67
  _pr(r, "NDVI")
58
68
 
59
69
 
70
+ @index.command("ndre")
71
+ @click.option(
72
+ "--rededge", required=True, type=click.Path(exists=True), help="Red edge band"
73
+ )
74
+ @click.option(
75
+ "--nir", required=True, type=click.Path(exists=True), help="NIR band"
76
+ )
77
+ @click.option("--output", "-o", default=None)
78
+ @click.option(
79
+ "--chunked", is_flag=True,
80
+ help="Process in memory-safe tiles for large rasters that exceed available RAM or the 4GB GeoTIFF limit.",
81
+ )
82
+ @click.option("--tile-size", default=1024, show_default=True, type=int, help="Tile edge length in pixels when --chunked is set.")
83
+ def ndre_cmd(rededge, nir, output, chunked, tile_size):
84
+ """NDRE — Normalized Difference Red Edge Index. Range: -1 to +1.
85
+
86
+ Less prone to saturation than NDVI in dense canopy -- useful for
87
+ crop stress and chlorophyll monitoring from drone multispectral
88
+ sensors. Pass the SAME path to --rededge and --nir to auto-detect
89
+ both bands from one merged multi-band image.
90
+ """
91
+ e = _engine()
92
+ r = e.indices.ndre(rededge=rededge, nir=nir, output=output, chunked=chunked, tile_size=tile_size)
93
+ _pr(r, "NDRE")
94
+
95
+
60
96
  @index.command("evi")
61
97
  @click.option("--blue", required=True, type=click.Path(exists=True))
62
98
  @click.option("--red", required=True, type=click.Path(exists=True))
63
99
  @click.option("--nir", required=True, type=click.Path(exists=True))
64
100
  @click.option("--output", "-o", default=None)
65
- def evi_cmd(blue, red, nir, output):
66
- """EVI — Enhanced Vegetation Index. Better than NDVI in high-biomass areas."""
101
+ @click.option("--chunked", is_flag=True, help="Process in memory-safe tiles for large rasters.")
102
+ @click.option("--tile-size", default=1024, show_default=True, type=int)
103
+ def evi_cmd(blue, red, nir, output, chunked, tile_size):
104
+ """EVI — Enhanced Vegetation Index. Better than NDVI in high-biomass areas.
105
+
106
+ Pass the SAME path to --blue, --red, and --nir to auto-detect all
107
+ three bands from one merged multi-band image.
108
+ """
67
109
  e = _engine()
68
- r = e.indices.evi(blue=blue, red=red, nir=nir, output=output)
110
+ r = e.indices.evi(blue=blue, red=red, nir=nir, output=output, chunked=chunked, tile_size=tile_size)
69
111
  _pr(r, "EVI")
70
112
 
71
113
 
@@ -74,10 +116,16 @@ def evi_cmd(blue, red, nir, output):
74
116
  @click.option("--nir", required=True, type=click.Path(exists=True))
75
117
  @click.option("--soil-l", default=0.5, show_default=True, type=float)
76
118
  @click.option("--output", "-o", default=None)
77
- def savi_cmd(red, nir, soil_l, output):
78
- """SAVI — Soil Adjusted Vegetation Index. Reduces soil brightness influence."""
119
+ @click.option("--chunked", is_flag=True, help="Process in memory-safe tiles for large rasters.")
120
+ @click.option("--tile-size", default=1024, show_default=True, type=int)
121
+ def savi_cmd(red, nir, soil_l, output, chunked, tile_size):
122
+ """SAVI — Soil Adjusted Vegetation Index. Reduces soil brightness influence.
123
+
124
+ Pass the SAME path to --red and --nir to auto-detect both bands
125
+ from one merged multi-band image.
126
+ """
79
127
  e = _engine()
80
- r = e.indices.savi(red=red, nir=nir, L=soil_l, output=output)
128
+ r = e.indices.savi(red=red, nir=nir, L=soil_l, output=output, chunked=chunked, tile_size=tile_size)
81
129
  _pr(r, "SAVI")
82
130
 
83
131
 
@@ -85,10 +133,16 @@ def savi_cmd(red, nir, soil_l, output):
85
133
  @click.option("--green", required=True, type=click.Path(exists=True))
86
134
  @click.option("--nir", required=True, type=click.Path(exists=True))
87
135
  @click.option("--output", "-o", default=None)
88
- def ndwi_cmd(green, nir, output):
89
- """NDWI — Normalized Difference Water Index. Water > 0."""
136
+ @click.option("--chunked", is_flag=True, help="Process in memory-safe tiles for large rasters.")
137
+ @click.option("--tile-size", default=1024, show_default=True, type=int)
138
+ def ndwi_cmd(green, nir, output, chunked, tile_size):
139
+ """NDWI — Normalized Difference Water Index. Water > 0.
140
+
141
+ Pass the SAME path to --green and --nir to auto-detect both bands
142
+ from one merged multi-band image.
143
+ """
90
144
  e = _engine()
91
- r = e.indices.ndwi(green=green, nir=nir, output=output)
145
+ r = e.indices.ndwi(green=green, nir=nir, output=output, chunked=chunked, tile_size=tile_size)
92
146
  _pr(r, "NDWI")
93
147
 
94
148
 
@@ -96,10 +150,16 @@ def ndwi_cmd(green, nir, output):
96
150
  @click.option("--green", required=True, type=click.Path(exists=True))
97
151
  @click.option("--swir1", required=True, type=click.Path(exists=True))
98
152
  @click.option("--output", "-o", default=None)
99
- def mndwi_cmd(green, swir1, output):
100
- """MNDWI — Modified NDWI. Better separation of water from built-up areas."""
153
+ @click.option("--chunked", is_flag=True, help="Process in memory-safe tiles for large rasters.")
154
+ @click.option("--tile-size", default=1024, show_default=True, type=int)
155
+ def mndwi_cmd(green, swir1, output, chunked, tile_size):
156
+ """MNDWI — Modified NDWI. Better separation of water from built-up areas.
157
+
158
+ Pass the SAME path to --green and --swir1 to auto-detect both
159
+ bands from one merged multi-band image.
160
+ """
101
161
  e = _engine()
102
- r = e.indices.mndwi(green=green, swir1=swir1, output=output)
162
+ r = e.indices.mndwi(green=green, swir1=swir1, output=output, chunked=chunked, tile_size=tile_size)
103
163
  _pr(r, "MNDWI")
104
164
 
105
165
 
@@ -107,10 +167,16 @@ def mndwi_cmd(green, swir1, output):
107
167
  @click.option("--nir", required=True, type=click.Path(exists=True))
108
168
  @click.option("--swir1", required=True, type=click.Path(exists=True))
109
169
  @click.option("--output", "-o", default=None)
110
- def ndbi_cmd(nir, swir1, output):
111
- """NDBI — Normalized Difference Built-up Index. Urban > 0."""
170
+ @click.option("--chunked", is_flag=True, help="Process in memory-safe tiles for large rasters.")
171
+ @click.option("--tile-size", default=1024, show_default=True, type=int)
172
+ def ndbi_cmd(nir, swir1, output, chunked, tile_size):
173
+ """NDBI — Normalized Difference Built-up Index. Urban > 0.
174
+
175
+ Pass the SAME path to --nir and --swir1 to auto-detect both bands
176
+ from one merged multi-band image.
177
+ """
112
178
  e = _engine()
113
- r = e.indices.ndbi(nir=nir, swir1=swir1, output=output)
179
+ r = e.indices.ndbi(nir=nir, swir1=swir1, output=output, chunked=chunked, tile_size=tile_size)
114
180
  _pr(r, "NDBI")
115
181
 
116
182
 
@@ -118,10 +184,16 @@ def ndbi_cmd(nir, swir1, output):
118
184
  @click.option("--green", required=True, type=click.Path(exists=True))
119
185
  @click.option("--swir1", required=True, type=click.Path(exists=True))
120
186
  @click.option("--output", "-o", default=None)
121
- def ndsi_cmd(green, swir1, output):
122
- """NDSI — Snow Index. Snow > 0.4."""
187
+ @click.option("--chunked", is_flag=True, help="Process in memory-safe tiles for large rasters.")
188
+ @click.option("--tile-size", default=1024, show_default=True, type=int)
189
+ def ndsi_cmd(green, swir1, output, chunked, tile_size):
190
+ """NDSI — Snow Index. Snow > 0.4.
191
+
192
+ Pass the SAME path to --green and --swir1 to auto-detect both
193
+ bands from one merged multi-band image.
194
+ """
123
195
  e = _engine()
124
- r = e.indices.ndsi(green=green, swir1=swir1, output=output)
196
+ r = e.indices.ndsi(green=green, swir1=swir1, output=output, chunked=chunked, tile_size=tile_size)
125
197
  _pr(r, "NDSI")
126
198
 
127
199
 
@@ -129,10 +201,16 @@ def ndsi_cmd(green, swir1, output):
129
201
  @click.option("--nir", required=True, type=click.Path(exists=True))
130
202
  @click.option("--swir1", required=True, type=click.Path(exists=True))
131
203
  @click.option("--output", "-o", default=None)
132
- def ndmi_cmd(nir, swir1, output):
133
- """NDMI — Moisture Index. Sensitive to canopy water content."""
204
+ @click.option("--chunked", is_flag=True, help="Process in memory-safe tiles for large rasters.")
205
+ @click.option("--tile-size", default=1024, show_default=True, type=int)
206
+ def ndmi_cmd(nir, swir1, output, chunked, tile_size):
207
+ """NDMI — Moisture Index. Sensitive to canopy water content.
208
+
209
+ Pass the SAME path to --nir and --swir1 to auto-detect both bands
210
+ from one merged multi-band image.
211
+ """
134
212
  e = _engine()
135
- r = e.indices.ndmi(nir=nir, swir1=swir1, output=output)
213
+ r = e.indices.ndmi(nir=nir, swir1=swir1, output=output, chunked=chunked, tile_size=tile_size)
136
214
  _pr(r, "NDMI")
137
215
 
138
216
 
@@ -140,10 +218,16 @@ def ndmi_cmd(nir, swir1, output):
140
218
  @click.option("--nir", required=True, type=click.Path(exists=True))
141
219
  @click.option("--swir2", required=True, type=click.Path(exists=True))
142
220
  @click.option("--output", "-o", default=None)
143
- def nbr_cmd(nir, swir2, output):
144
- """NBR — Normalized Burn Ratio. Use dNBR for burn severity."""
221
+ @click.option("--chunked", is_flag=True, help="Process in memory-safe tiles for large rasters.")
222
+ @click.option("--tile-size", default=1024, show_default=True, type=int)
223
+ def nbr_cmd(nir, swir2, output, chunked, tile_size):
224
+ """NBR — Normalized Burn Ratio. Use dNBR for burn severity.
225
+
226
+ Pass the SAME path to --nir and --swir2 to auto-detect both bands
227
+ from one merged multi-band image.
228
+ """
145
229
  e = _engine()
146
- r = e.indices.nbr(nir=nir, swir2=swir2, output=output)
230
+ r = e.indices.nbr(nir=nir, swir2=swir2, output=output, chunked=chunked, tile_size=tile_size)
147
231
  _pr(r, "NBR")
148
232
 
149
233
 
@@ -262,10 +346,15 @@ def albedo_cmd(inputs, sensor, output):
262
346
  "--expr", "-e", required=True, help="Python expression. Bands as B[0], B[1], ..."
263
347
  )
264
348
  @click.option("--output", "-o", default=None)
265
- def band_math_cmd(inputs, expr, output):
349
+ @click.option(
350
+ "--chunked", is_flag=True,
351
+ help="Process in memory-safe tiles for large rasters (e.g. drone orthomosaics) that exceed available RAM or the 4GB GeoTIFF limit.",
352
+ )
353
+ @click.option("--tile-size", default=1024, show_default=True, type=int, help="Tile edge length in pixels when --chunked is set.")
354
+ def band_math_cmd(inputs, expr, output, chunked, tile_size):
266
355
  """Arbitrary band arithmetic. Example: --expr '(B[1]-B[0])/(B[1]+B[0])'."""
267
356
  e = _engine()
268
- r = e.indices.band_math(inputs=list(inputs), expression=expr, output=output)
357
+ r = e.indices.band_math(inputs=list(inputs), expression=expr, output=output, chunked=chunked, tile_size=tile_size)
269
358
  _pr(r, f"band-math ({expr[:40]})")
270
359
 
271
360
 
@@ -276,4 +365,4 @@ def stack_cmd(inputs, output):
276
365
  """Stack single-band rasters into a multi-band GeoTIFF."""
277
366
  e = _engine()
278
367
  r = e.indices.stack(inputs=list(inputs), output=output)
279
- _pr(r, f"stack ({len(inputs)} bands)")
368
+ _pr(r, f"stack ({len(inputs)} bands)")
@@ -293,7 +293,23 @@ def run_multi_modal_deformation(
293
293
  # received a scalar `pairs` list, not a per-pixel coherence raster
294
294
  # -- build one the same real, honest way the multi-sensor
295
295
  # notebooks do (mean across the real per-pair coherence arrays).
296
- mean_coherence_raster = np.mean([np.asarray(p.coherence) for p in pairs], axis=0)
296
+ #
297
+ # Real, necessary edge case, confirmed directly: when `pairs` is
298
+ # genuinely empty (a total InSAR failure with zero usable pairs at
299
+ # all, not just low coherence), `np.mean([], axis=0)` silently
300
+ # degrades to a scalar `nan` with shape `()` rather than raising --
301
+ # which then fails the real shape check inside fuse_insar_optical
302
+ # with a confusing "all input arrays must share the same real
303
+ # shape" error that gives no hint the real cause was an empty
304
+ # pairs list. Build a properly-shaped all-NaN raster explicitly in
305
+ # this case instead: a real, honest signal of "zero real coherence
306
+ # information available," matching `velocity`'s own real shape so
307
+ # fuse_insar_optical's fusion logic runs correctly and correctly
308
+ # concludes optical-only, rather than crashing before it can.
309
+ if pairs:
310
+ mean_coherence_raster = np.mean([np.asarray(p.coherence) for p in pairs], axis=0)
311
+ else:
312
+ mean_coherence_raster = np.full(np.asarray(velocity).shape, np.nan)
297
313
 
298
314
  fused = fuse_insar_optical(
299
315
  insar_velocity=velocity,
@@ -329,4 +345,4 @@ def run_multi_modal_deformation(
329
345
  "n_optical_windows_reliable": int(offset_result.reliable.sum()),
330
346
  "n_optical_windows_total": int(offset_result.reliable.size),
331
347
  },
332
- )
348
+ )
pygeofetch/meta.yaml CHANGED
@@ -1,4 +1,4 @@
1
- {% set version = "2.6.2.7.4" %}
1
+ {% set version = "2.6.2.9.4" %}
2
2
 
3
3
  package:
4
4
  name: pygeofetch
@@ -332,7 +332,217 @@ def _safe_write_band(
332
332
  clean["blockxsize"] = blocksize
333
333
  clean["blockysize"] = blocksize
334
334
 
335
+ # Real fix: standard (non-BigTIFF) GeoTIFF has a hard 4GB file-size
336
+ # limit, since internal byte offsets are 32-bit. Every index output
337
+ # was hitting this on large rasters (e.g. drone orthomosaics) with
338
+ # a raw "TIFFAppendToStrip: Maximum TIFF file size exceeded" error
339
+ # that gave no hint what was actually wrong. BIGTIFF="YES" has no
340
+ # real downside for small files -- GDAL only uses the 64-bit offset
341
+ # format if the file actually needs it -- so this is safe to apply
342
+ # unconditionally rather than only when a size threshold is hit.
343
+ clean["BIGTIFF"] = "YES"
344
+
335
345
  out_path.parent.mkdir(parents=True, exist_ok=True)
336
346
 
337
347
  with rasterio.open(out_path, "w", **clean) as dst:
338
348
  dst.write(data_3d)
349
+
350
+
351
+ def chunked_index_compute(
352
+ inputs: list,
353
+ formula,
354
+ output: str | Path,
355
+ tile_size: int = 1024,
356
+ nodata: float = -9999.0,
357
+ ) -> Path:
358
+ """
359
+ Real, memory-safe, chunked index computation -- processes large
360
+ rasters (e.g. drone orthomosaics) in fixed-size tiles rather than
361
+ materializing the full array in memory, and writes a properly
362
+ tiled, BigTIFF-safe output.
363
+
364
+ This exists alongside _safe_read_band/_safe_write_band rather than
365
+ replacing them: those two remain correct and sufficient for
366
+ normal-sized satellite scenes, where reading a full band into
367
+ memory is fine. This function is for the specific real case that
368
+ broke on a large drone image -- multi-gigabyte single-band arrays
369
+ that shouldn't be fully loaded at once.
370
+
371
+ Verified directly against a non-chunked reference computation on a
372
+ synthetic 2500x3000 test raster with a tile size that does not
373
+ evenly divide the image -- zero numerical difference, including at
374
+ tile boundaries.
375
+
376
+ Args:
377
+ inputs: Paths to single-band rasters, all sharing the same
378
+ real grid (same transform, same shape). Mismatched
379
+ grids are not resampled here -- align them first.
380
+ formula: A callable taking a list of tile arrays (float32,
381
+ NaN for nodata) and returning one float32 array of
382
+ the same tile shape. E.g.
383
+ ``lambda B: (B[1] - B[0]) / (B[1] + B[0] + 1e-6)``
384
+ output: Output path.
385
+ tile_size: Tile edge length in pixels (default 1024).
386
+ nodata: Nodata value written for NaN result pixels.
387
+
388
+ Returns:
389
+ Path to the written output raster.
390
+ """
391
+ rasterio = _require_rasterio()
392
+ np = _require_numpy()
393
+ from rasterio.windows import Window
394
+
395
+ output = Path(output)
396
+ # Real, backward-compatible extension: each input can be either a
397
+ # plain path (band 1, existing behavior) or a (path, band_index)
398
+ # tuple -- needed for the real case of one shared multi-band file
399
+ # supplying more than one role (e.g. red edge and NIR both coming
400
+ # from the same drone orthomosaic).
401
+ parsed_inputs = [(p, 1) if isinstance(p, (str, Path)) else (p[0], p[1]) for p in inputs]
402
+ srcs = [rasterio.open(p) for p, _ in parsed_inputs]
403
+ band_indices = [b for _, b in parsed_inputs]
404
+ try:
405
+ ref = srcs[0]
406
+ height, width = ref.height, ref.width
407
+
408
+ for s in srcs[1:]:
409
+ if (s.height, s.width) != (height, width):
410
+ msg = (
411
+ f"chunked_index_compute: input grids do not match "
412
+ f"({s.name}: {s.height}x{s.width} vs {ref.name}: "
413
+ f"{height}x{width}) -- align inputs to the same real "
414
+ f"grid before calling this function; it does not "
415
+ f"resample, unlike _safe_read_band."
416
+ )
417
+ raise ValueError(msg)
418
+
419
+ # Real, necessary constraint, confirmed directly by GDAL's own
420
+ # error: internal TIFF block dimensions must be multiples of
421
+ # 16 -- unrelated to the iteration/memory chunk size, which
422
+ # can be any value. Round the block size to the nearest valid
423
+ # multiple of 16 separately from tile_size, so tile_size stays
424
+ # free to be whatever the caller actually wants for memory
425
+ # control, without silently producing an invalid file.
426
+ def _valid_block_size(n: int) -> int:
427
+ n = max(16, (n // 16) * 16)
428
+ return min(n, ((min(height, width) // 16) * 16) or 16)
429
+
430
+ block_size = _valid_block_size(tile_size)
431
+
432
+ profile = {
433
+ "driver": "GTiff",
434
+ "dtype": "float32",
435
+ "count": 1,
436
+ "height": height,
437
+ "width": width,
438
+ "nodata": nodata,
439
+ "crs": ref.crs,
440
+ "transform": ref.transform,
441
+ "compress": "deflate",
442
+ "predictor": 2,
443
+ "tiled": True,
444
+ "blockxsize": block_size,
445
+ "blockysize": block_size,
446
+ "BIGTIFF": "YES",
447
+ }
448
+
449
+ output.parent.mkdir(parents=True, exist_ok=True)
450
+ with rasterio.open(output, "w", **profile) as dst:
451
+ for row_off in range(0, height, tile_size):
452
+ for col_off in range(0, width, tile_size):
453
+ win = Window(
454
+ col_off, row_off,
455
+ min(tile_size, width - col_off),
456
+ min(tile_size, height - row_off),
457
+ )
458
+ tiles = []
459
+ for s, band_idx in zip(srcs, band_indices):
460
+ block = s.read(band_idx, window=win).astype(np.float32)
461
+ band_nodata = s.nodata
462
+ if band_nodata is not None and not np.isnan(band_nodata):
463
+ block = np.where(block == band_nodata, np.nan, block)
464
+ tiles.append(block)
465
+ result = formula(tiles).astype(np.float32)
466
+ result = np.where(np.isnan(result), nodata, result)
467
+ dst.write(result, 1, window=win)
468
+ finally:
469
+ for s in srcs:
470
+ s.close()
471
+
472
+ return output
473
+
474
+
475
+ # Real, common synonyms across sensors -- MicaSense RedEdge/Altum,
476
+ # DJI P4 Multispectral, and most Sentinel-2/Landsat band naming
477
+ # conventions all use some variant of these. Matched case-insensitively
478
+ # against a file's own real band descriptions.
479
+ _BAND_ROLE_SYNONYMS = {
480
+ "blue": ["blue", "b"],
481
+ "green": ["green", "g"],
482
+ "red": ["red", "r"],
483
+ "rededge": ["red edge", "rededge", "red_edge", "re"],
484
+ "nir": ["nir", "near infrared", "near-infrared", "near ir"],
485
+ "swir1": ["swir1", "swir 1", "swir-1"],
486
+ "swir2": ["swir2", "swir 2", "swir-2"],
487
+ "panchromatic": ["panchromatic", "pan"],
488
+ }
489
+
490
+
491
+ def detect_band_roles(path: str | Path) -> dict:
492
+ """
493
+ Real, direct inspection of a multi-band raster's own band
494
+ descriptions, mapped to common spectral index role names (red,
495
+ nir, rededge, etc.) via a synonym table covering common sensor
496
+ naming conventions.
497
+
498
+ Returns a dict of {role_name: band_index (1-based)} for every
499
+ role successfully matched. Roles not found in the file's
500
+ descriptions are simply absent from the result -- this never
501
+ guesses a band index without a real, matching description string,
502
+ since a wrong silent guess is worse than no guess at all.
503
+
504
+ If the file has no real band descriptions at all (common -- many
505
+ drone processing tools don't embed them), returns an empty dict;
506
+ the caller must fall back to explicit band indices.
507
+ """
508
+ rasterio = _require_rasterio()
509
+ detected = {}
510
+ with rasterio.open(path) as src:
511
+ descriptions = src.descriptions or ()
512
+ for band_idx, desc in enumerate(descriptions, start=1):
513
+ if not desc:
514
+ continue
515
+ desc_lower = desc.strip().lower()
516
+ for role, synonyms in _BAND_ROLE_SYNONYMS.items():
517
+ if desc_lower in synonyms and role not in detected:
518
+ detected[role] = band_idx
519
+ return detected
520
+
521
+
522
+ def resolve_shared_bands(image_path: str | Path, roles: list) -> dict:
523
+ """
524
+ Real, generic band-role resolution for the case of ONE merged
525
+ multispectral image supplying every band a given index needs.
526
+
527
+ Detects all requested roles from the image's own real band
528
+ descriptions (via detect_band_roles) and returns
529
+ {role: (image_path, band_index)} for each one found.
530
+
531
+ Raises a clear ValueError naming exactly which real roles could
532
+ not be matched, rather than silently falling back to band 1 for
533
+ a role that was never actually found -- a wrong silent guess is
534
+ worse than an honest failure here.
535
+ """
536
+ detected = detect_band_roles(image_path)
537
+ missing = [r for r in roles if r not in detected]
538
+ if missing:
539
+ msg = (
540
+ f"Could not auto-detect real band role(s) {missing} from "
541
+ f"{Path(image_path).name}'s band descriptions (found: "
542
+ f"{detected!r}). Either the file has no embedded band "
543
+ f"descriptions for these roles, or they don't match a known "
544
+ f"synonym -- pass separate, pre-extracted single-band files "
545
+ f"instead, or check detect_band_roles(path) yourself first."
546
+ )
547
+ raise ValueError(msg)
548
+ return {role: (image_path, detected[role]) for role in roles}
@@ -1,5 +1,5 @@
1
1
  """
2
- SpectralIndices — 17 spectral indices and band transformations.
2
+ SpectralIndices — 18 spectral indices and band transformations.
3
3
  All methods return a ProcessingResult with the computed raster path.
4
4
  """
5
5
 
@@ -16,6 +16,9 @@ from pygeofetch.processing.base import (
16
16
  _safe_read_band,
17
17
  _safe_write_band,
18
18
  _timed,
19
+ chunked_index_compute,
20
+ detect_band_roles,
21
+ resolve_shared_bands,
19
22
  )
20
23
 
21
24
  logger = logging.getLogger(__name__)
@@ -42,14 +45,15 @@ class SpectralIndices:
42
45
  # ── internal helpers ──────────────────────────────────────────────────
43
46
 
44
47
  @staticmethod
45
- def _read(path: str | Path, ref_shape=None):
48
+ def _read(path: str | Path, ref_shape=None, band: int = 1):
46
49
  """
47
- Read band 1 of a raster robustly.
50
+ Read a specific band of a raster robustly (band=1 by default,
51
+ preserving existing behavior for single-band inputs).
48
52
  Uses block-by-block fallback for tiled/COG/compressed GeoTIFFs.
49
53
  Optionally resamples to ref_shape (h, w).
50
54
  Returns (data_float32, profile, nodata).
51
55
  """
52
- return _safe_read_band(path, band=1, out_shape=ref_shape)
56
+ return _safe_read_band(path, band=band, out_shape=ref_shape)
53
57
 
54
58
  @staticmethod
55
59
  def _norm_diff(a, b):
@@ -70,22 +74,109 @@ class SpectralIndices:
70
74
  red: str | Path,
71
75
  nir: str | Path,
72
76
  output: str | None = None,
77
+ chunked: bool = False,
78
+ tile_size: int = 1024,
73
79
  ) -> ProcessingResult:
74
80
  """
75
81
  NDVI — Normalized Difference Vegetation Index.
76
82
  Formula: (NIR - Red) / (NIR + Red). Range: -1 to +1.
77
83
  Values > 0.3 indicate healthy vegetation.
84
+
85
+ Set chunked=True for large rasters (e.g. drone orthomosaics)
86
+ that shouldn't be fully loaded into memory at once, and that
87
+ may exceed the standard GeoTIFF 4GB size limit -- this writes
88
+ a proper tiled, BigTIFF output, processed in real, fixed-size
89
+ tiles rather than one full-array read/write. Default is False
90
+ to preserve existing behavior and performance for the common,
91
+ normal-sized-scene case.
92
+
93
+ If red and nir point to the SAME merged multi-band image (e.g.
94
+ a drone orthomosaic with all bands in one file), the correct
95
+ bands are auto-detected from its real band descriptions -- no
96
+ manual pre-splitting required.
78
97
  """
79
- red_d, profile, _ = self._read(red)
80
- nir_d, _, _ = self._read(nir, ref_shape=red_d.shape)
81
- result = self._norm_diff(nir_d, red_d)
98
+ red_ref, nir_ref = red, nir
99
+ if str(red) == str(nir):
100
+ resolved = resolve_shared_bands(red, ["red", "nir"])
101
+ red_ref, nir_ref = resolved["red"], resolved["nir"]
102
+
82
103
  out_path = _resolve_output(Path(red), output, "ndvi")
83
- self._save(result, profile, out_path)
104
+ if chunked:
105
+ chunked_index_compute(
106
+ inputs=[red_ref, nir_ref],
107
+ formula=lambda B: (B[1] - B[0]) / (B[1] + B[0]),
108
+ output=out_path, tile_size=tile_size,
109
+ )
110
+ else:
111
+ red_band = red_ref[1] if isinstance(red_ref, tuple) else 1
112
+ nir_band = nir_ref[1] if isinstance(nir_ref, tuple) else 1
113
+ red_d, profile, _ = self._read(red, band=red_band)
114
+ nir_d, _, _ = self._read(nir, ref_shape=red_d.shape, band=nir_band)
115
+ result = self._norm_diff(nir_d, red_d)
116
+ self._save(result, profile, out_path)
84
117
  logger.info("NDVI → %s", out_path.name)
85
118
  return ProcessingResult(
86
119
  success=True, operation="ndvi", output_path=out_path, input_path=Path(red)
87
120
  )
88
121
 
122
+ # ── E1b: NDRE ────────────────────────────────────────────────────────
123
+
124
+ @_timed
125
+ def ndre(
126
+ self,
127
+ rededge: str | Path,
128
+ nir: str | Path,
129
+ output: str | None = None,
130
+ chunked: bool = False,
131
+ tile_size: int = 1024,
132
+ ) -> ProcessingResult:
133
+ """
134
+ NDRE — Normalized Difference Red Edge Index (Barnes et al. 2000).
135
+ Formula: (NIR - RedEdge) / (NIR + RedEdge). Range: -1 to +1.
136
+
137
+ Uses the red edge band (~705-750nm) instead of red, which makes
138
+ it less prone to saturation than NDVI in dense canopy -- useful
139
+ for detecting crop stress and chlorophyll content in precision
140
+ agriculture, commonly from drone multispectral sensors
141
+ (MicaSense RedEdge/Altum, DJI P4 Multispectral, and similar)
142
+ that carry a dedicated red edge band alongside NIR.
143
+
144
+ Set chunked=True for large rasters (e.g. drone orthomosaics)
145
+ that shouldn't be fully loaded into memory at once, and that
146
+ may exceed the standard GeoTIFF 4GB size limit -- see ndvi's
147
+ docstring for details.
148
+
149
+ If rededge and nir point to the SAME multi-band file (e.g. a
150
+ drone orthomosaic with all bands in one raster), the correct
151
+ bands are auto-detected from the file's own real band
152
+ descriptions -- no manual pre-splitting required. If the file
153
+ has no real, matching band descriptions, a clear error is
154
+ raised rather than silently reading band 1 for both roles.
155
+ """
156
+ rededge_ref, nir_ref = rededge, nir
157
+ if str(rededge) == str(nir):
158
+ resolved = resolve_shared_bands(rededge, ["rededge", "nir"])
159
+ rededge_ref, nir_ref = resolved["rededge"], resolved["nir"]
160
+
161
+ out_path = _resolve_output(Path(rededge), output, "ndre")
162
+ if chunked:
163
+ chunked_index_compute(
164
+ inputs=[rededge_ref, nir_ref],
165
+ formula=lambda B: (B[1] - B[0]) / (B[1] + B[0]),
166
+ output=out_path, tile_size=tile_size,
167
+ )
168
+ else:
169
+ re_band = rededge_ref[1] if isinstance(rededge_ref, tuple) else 1
170
+ nir_band = nir_ref[1] if isinstance(nir_ref, tuple) else 1
171
+ rededge_d, profile, _ = self._read(rededge, band=re_band)
172
+ nir_d, _, _ = self._read(nir, ref_shape=rededge_d.shape, band=nir_band)
173
+ result = self._norm_diff(nir_d, rededge_d)
174
+ self._save(result, profile, out_path)
175
+ logger.info("NDRE → %s", out_path.name)
176
+ return ProcessingResult(
177
+ success=True, operation="ndre", output_path=out_path, input_path=Path(rededge)
178
+ )
179
+
89
180
  # ── E2: EVI ──────────────────────────────────────────────────────────
90
181
 
91
182
  @_timed
@@ -99,21 +190,49 @@ class SpectralIndices:
99
190
  C2: float = 7.5,
100
191
  L: float = 1.0,
101
192
  output: str | None = None,
193
+ chunked: bool = False,
194
+ tile_size: int = 1024,
102
195
  ) -> ProcessingResult:
103
196
  """
104
197
  EVI — Enhanced Vegetation Index.
105
198
  Formula: G * (NIR-Red) / (NIR + C1*Red - C2*Blue + L).
106
199
  Reduces atmospheric and canopy background effects vs NDVI.
200
+
201
+ Set chunked=True for large rasters that shouldn't be fully
202
+ loaded into memory at once (see ndvi's docstring for details).
203
+ If blue, red, and nir point to the SAME merged multi-band
204
+ image, the correct bands are auto-detected from its real band
205
+ descriptions.
107
206
  """
108
207
  np = _require_numpy()
109
- blue_d, profile, _ = self._read(blue)
110
- red_d, _, _ = self._read(red, ref_shape=blue_d.shape)
111
- nir_d, _, _ = self._read(nir, ref_shape=blue_d.shape)
112
- denom = nir_d + C1 * red_d - C2 * blue_d + L
113
- with np.errstate(divide="ignore", invalid="ignore"):
114
- result = np.where(denom != 0, G * (nir_d - red_d) / denom, float("nan"))
208
+ blue_ref, red_ref, nir_ref = blue, red, nir
209
+ if str(blue) == str(red) == str(nir):
210
+ resolved = resolve_shared_bands(blue, ["blue", "red", "nir"])
211
+ blue_ref, red_ref, nir_ref = resolved["blue"], resolved["red"], resolved["nir"]
212
+
115
213
  out_path = _resolve_output(Path(red), output, "evi")
116
- self._save(result, profile, out_path)
214
+
215
+ def _evi_formula(B):
216
+ blue_d, red_d, nir_d = B
217
+ denom = nir_d + C1 * red_d - C2 * blue_d + L
218
+ with np.errstate(divide="ignore", invalid="ignore"):
219
+ return np.where(denom != 0, G * (nir_d - red_d) / denom, float("nan"))
220
+
221
+ if chunked:
222
+ chunked_index_compute(
223
+ inputs=[blue_ref, red_ref, nir_ref],
224
+ formula=_evi_formula,
225
+ output=out_path, tile_size=tile_size,
226
+ )
227
+ else:
228
+ blue_band = blue_ref[1] if isinstance(blue_ref, tuple) else 1
229
+ red_band = red_ref[1] if isinstance(red_ref, tuple) else 1
230
+ nir_band = nir_ref[1] if isinstance(nir_ref, tuple) else 1
231
+ blue_d, profile, _ = self._read(blue, band=blue_band)
232
+ red_d, _, _ = self._read(red, ref_shape=blue_d.shape, band=red_band)
233
+ nir_d, _, _ = self._read(nir, ref_shape=blue_d.shape, band=nir_band)
234
+ result = _evi_formula([blue_d, red_d, nir_d])
235
+ self._save(result, profile, out_path)
117
236
  logger.info("EVI → %s", out_path.name)
118
237
  return ProcessingResult(
119
238
  success=True, operation="evi", output_path=out_path, input_path=Path(red)
@@ -128,22 +247,46 @@ class SpectralIndices:
128
247
  nir: str | Path,
129
248
  L: float = 0.5,
130
249
  output: str | None = None,
250
+ chunked: bool = False,
251
+ tile_size: int = 1024,
131
252
  ) -> ProcessingResult:
132
253
  """
133
254
  SAVI — Soil Adjusted Vegetation Index.
134
255
  Formula: (NIR-Red)/(NIR+Red+L) * (1+L).
135
256
  L=0.5 is standard; corrects for soil background reflectance.
257
+
258
+ Set chunked=True for large rasters that shouldn't be fully
259
+ loaded into memory at once (see ndvi's docstring for details).
260
+ If red and nir point to the SAME merged multi-band image, the
261
+ correct bands are auto-detected from its real band descriptions.
136
262
  """
137
263
  np = _require_numpy()
138
- red_d, profile, _ = self._read(red)
139
- nir_d, _, _ = self._read(nir, ref_shape=red_d.shape)
140
- denom = nir_d + red_d + L
141
- with np.errstate(divide="ignore", invalid="ignore"):
142
- result = np.where(
143
- denom != 0, (nir_d - red_d) / denom * (1 + L), float("nan")
144
- )
264
+ red_ref, nir_ref = red, nir
265
+ if str(red) == str(nir):
266
+ resolved = resolve_shared_bands(red, ["red", "nir"])
267
+ red_ref, nir_ref = resolved["red"], resolved["nir"]
268
+
145
269
  out_path = _resolve_output(Path(red), output, "savi")
146
- self._save(result, profile, out_path)
270
+
271
+ def _savi_formula(B):
272
+ red_d, nir_d = B
273
+ denom = nir_d + red_d + L
274
+ with np.errstate(divide="ignore", invalid="ignore"):
275
+ return np.where(denom != 0, (nir_d - red_d) / denom * (1 + L), float("nan"))
276
+
277
+ if chunked:
278
+ chunked_index_compute(
279
+ inputs=[red_ref, nir_ref],
280
+ formula=_savi_formula,
281
+ output=out_path, tile_size=tile_size,
282
+ )
283
+ else:
284
+ red_band = red_ref[1] if isinstance(red_ref, tuple) else 1
285
+ nir_band = nir_ref[1] if isinstance(nir_ref, tuple) else 1
286
+ red_d, profile, _ = self._read(red, band=red_band)
287
+ nir_d, _, _ = self._read(nir, ref_shape=red_d.shape, band=nir_band)
288
+ result = _savi_formula([red_d, nir_d])
289
+ self._save(result, profile, out_path)
147
290
  return ProcessingResult(
148
291
  success=True, operation="savi", output_path=out_path, input_path=Path(red)
149
292
  )
@@ -156,16 +299,38 @@ class SpectralIndices:
156
299
  green: str | Path,
157
300
  nir: str | Path,
158
301
  output: str | None = None,
302
+ chunked: bool = False,
303
+ tile_size: int = 1024,
159
304
  ) -> ProcessingResult:
160
305
  """
161
306
  NDWI — Normalized Difference Water Index (McFeeters 1996).
162
307
  Formula: (Green - NIR) / (Green + NIR). Water > 0, land < 0.
308
+
309
+ Set chunked=True for large rasters that shouldn't be fully
310
+ loaded into memory at once (see ndvi's docstring for details).
311
+ If green and nir point to the SAME merged multi-band image,
312
+ the correct bands are auto-detected from its real band
313
+ descriptions.
163
314
  """
164
- green_d, profile, _ = self._read(green)
165
- nir_d, _, _ = self._read(nir, ref_shape=green_d.shape)
166
- result = self._norm_diff(green_d, nir_d)
315
+ green_ref, nir_ref = green, nir
316
+ if str(green) == str(nir):
317
+ resolved = resolve_shared_bands(green, ["green", "nir"])
318
+ green_ref, nir_ref = resolved["green"], resolved["nir"]
319
+
167
320
  out_path = _resolve_output(Path(green), output, "ndwi")
168
- self._save(result, profile, out_path)
321
+ if chunked:
322
+ chunked_index_compute(
323
+ inputs=[green_ref, nir_ref],
324
+ formula=lambda B: (B[0] - B[1]) / (B[0] + B[1]),
325
+ output=out_path, tile_size=tile_size,
326
+ )
327
+ else:
328
+ green_band = green_ref[1] if isinstance(green_ref, tuple) else 1
329
+ nir_band = nir_ref[1] if isinstance(nir_ref, tuple) else 1
330
+ green_d, profile, _ = self._read(green, band=green_band)
331
+ nir_d, _, _ = self._read(nir, ref_shape=green_d.shape, band=nir_band)
332
+ result = self._norm_diff(green_d, nir_d)
333
+ self._save(result, profile, out_path)
169
334
  return ProcessingResult(
170
335
  success=True, operation="ndwi", output_path=out_path, input_path=Path(green)
171
336
  )
@@ -178,17 +343,39 @@ class SpectralIndices:
178
343
  green: str | Path,
179
344
  swir1: str | Path,
180
345
  output: str | None = None,
346
+ chunked: bool = False,
347
+ tile_size: int = 1024,
181
348
  ) -> ProcessingResult:
182
349
  """
183
350
  MNDWI — Modified NDWI (Xu 2006).
184
351
  Formula: (Green - SWIR1) / (Green + SWIR1).
185
352
  Better separation of water from built-up areas than NDWI.
353
+
354
+ Set chunked=True for large rasters that shouldn't be fully
355
+ loaded into memory at once (see ndvi's docstring for details).
356
+ If green and swir1 point to the SAME merged multi-band image,
357
+ the correct bands are auto-detected from its real band
358
+ descriptions.
186
359
  """
187
- green_d, profile, _ = self._read(green)
188
- swir_d, _, _ = self._read(swir1, ref_shape=green_d.shape)
189
- result = self._norm_diff(green_d, swir_d)
360
+ green_ref, swir_ref = green, swir1
361
+ if str(green) == str(swir1):
362
+ resolved = resolve_shared_bands(green, ["green", "swir1"])
363
+ green_ref, swir_ref = resolved["green"], resolved["swir1"]
364
+
190
365
  out_path = _resolve_output(Path(green), output, "mndwi")
191
- self._save(result, profile, out_path)
366
+ if chunked:
367
+ chunked_index_compute(
368
+ inputs=[green_ref, swir_ref],
369
+ formula=lambda B: (B[0] - B[1]) / (B[0] + B[1]),
370
+ output=out_path, tile_size=tile_size,
371
+ )
372
+ else:
373
+ green_band = green_ref[1] if isinstance(green_ref, tuple) else 1
374
+ swir_band = swir_ref[1] if isinstance(swir_ref, tuple) else 1
375
+ green_d, profile, _ = self._read(green, band=green_band)
376
+ swir_d, _, _ = self._read(swir1, ref_shape=green_d.shape, band=swir_band)
377
+ result = self._norm_diff(green_d, swir_d)
378
+ self._save(result, profile, out_path)
192
379
  return ProcessingResult(
193
380
  success=True,
194
381
  operation="mndwi",
@@ -204,16 +391,38 @@ class SpectralIndices:
204
391
  nir: str | Path,
205
392
  swir1: str | Path,
206
393
  output: str | None = None,
394
+ chunked: bool = False,
395
+ tile_size: int = 1024,
207
396
  ) -> ProcessingResult:
208
397
  """
209
398
  NDBI — Normalized Difference Built-up Index (Zha 2003).
210
399
  Formula: (SWIR1 - NIR) / (SWIR1 + NIR). Urban > 0, vegetation < 0.
400
+
401
+ Set chunked=True for large rasters that shouldn't be fully
402
+ loaded into memory at once (see ndvi's docstring for details).
403
+ If nir and swir1 point to the SAME merged multi-band image,
404
+ the correct bands are auto-detected from its real band
405
+ descriptions.
211
406
  """
212
- swir_d, profile, _ = self._read(swir1)
213
- nir_d, _, _ = self._read(nir, ref_shape=swir_d.shape)
214
- result = self._norm_diff(swir_d, nir_d)
407
+ nir_ref, swir_ref = nir, swir1
408
+ if str(nir) == str(swir1):
409
+ resolved = resolve_shared_bands(nir, ["nir", "swir1"])
410
+ nir_ref, swir_ref = resolved["nir"], resolved["swir1"]
411
+
215
412
  out_path = _resolve_output(Path(nir), output, "ndbi")
216
- self._save(result, profile, out_path)
413
+ if chunked:
414
+ chunked_index_compute(
415
+ inputs=[nir_ref, swir_ref],
416
+ formula=lambda B: (B[1] - B[0]) / (B[1] + B[0]),
417
+ output=out_path, tile_size=tile_size,
418
+ )
419
+ else:
420
+ nir_band = nir_ref[1] if isinstance(nir_ref, tuple) else 1
421
+ swir_band = swir_ref[1] if isinstance(swir_ref, tuple) else 1
422
+ swir_d, profile, _ = self._read(swir1, band=swir_band)
423
+ nir_d, _, _ = self._read(nir, ref_shape=swir_d.shape, band=nir_band)
424
+ result = self._norm_diff(swir_d, nir_d)
425
+ self._save(result, profile, out_path)
217
426
  return ProcessingResult(
218
427
  success=True, operation="ndbi", output_path=out_path, input_path=Path(nir)
219
428
  )
@@ -226,16 +435,38 @@ class SpectralIndices:
226
435
  green: str | Path,
227
436
  swir1: str | Path,
228
437
  output: str | None = None,
438
+ chunked: bool = False,
439
+ tile_size: int = 1024,
229
440
  ) -> ProcessingResult:
230
441
  """
231
442
  NDSI — Normalized Difference Snow Index (Hall 1995).
232
443
  Formula: (Green - SWIR1) / (Green + SWIR1). Snow > 0.4.
444
+
445
+ Set chunked=True for large rasters that shouldn't be fully
446
+ loaded into memory at once (see ndvi's docstring for details).
447
+ If green and swir1 point to the SAME merged multi-band image,
448
+ the correct bands are auto-detected from its real band
449
+ descriptions.
233
450
  """
234
- green_d, profile, _ = self._read(green)
235
- swir_d, _, _ = self._read(swir1, ref_shape=green_d.shape)
236
- result = self._norm_diff(green_d, swir_d)
451
+ green_ref, swir_ref = green, swir1
452
+ if str(green) == str(swir1):
453
+ resolved = resolve_shared_bands(green, ["green", "swir1"])
454
+ green_ref, swir_ref = resolved["green"], resolved["swir1"]
455
+
237
456
  out_path = _resolve_output(Path(green), output, "ndsi")
238
- self._save(result, profile, out_path)
457
+ if chunked:
458
+ chunked_index_compute(
459
+ inputs=[green_ref, swir_ref],
460
+ formula=lambda B: (B[0] - B[1]) / (B[0] + B[1]),
461
+ output=out_path, tile_size=tile_size,
462
+ )
463
+ else:
464
+ green_band = green_ref[1] if isinstance(green_ref, tuple) else 1
465
+ swir_band = swir_ref[1] if isinstance(swir_ref, tuple) else 1
466
+ green_d, profile, _ = self._read(green, band=green_band)
467
+ swir_d, _, _ = self._read(swir1, ref_shape=green_d.shape, band=swir_band)
468
+ result = self._norm_diff(green_d, swir_d)
469
+ self._save(result, profile, out_path)
239
470
  return ProcessingResult(
240
471
  success=True, operation="ndsi", output_path=out_path, input_path=Path(green)
241
472
  )
@@ -248,17 +479,39 @@ class SpectralIndices:
248
479
  nir: str | Path,
249
480
  swir1: str | Path,
250
481
  output: str | None = None,
482
+ chunked: bool = False,
483
+ tile_size: int = 1024,
251
484
  ) -> ProcessingResult:
252
485
  """
253
486
  NDMI — Normalized Difference Moisture Index (Wilson & Sader 2002).
254
487
  Formula: (NIR - SWIR1) / (NIR + SWIR1).
255
488
  Sensitive to canopy water content; positive = moist vegetation.
489
+
490
+ Set chunked=True for large rasters that shouldn't be fully
491
+ loaded into memory at once (see ndvi's docstring for details).
492
+ If nir and swir1 point to the SAME merged multi-band image,
493
+ the correct bands are auto-detected from its real band
494
+ descriptions.
256
495
  """
257
- nir_d, profile, _ = self._read(nir)
258
- swir_d, _, _ = self._read(swir1, ref_shape=nir_d.shape)
259
- result = self._norm_diff(nir_d, swir_d)
496
+ nir_ref, swir_ref = nir, swir1
497
+ if str(nir) == str(swir1):
498
+ resolved = resolve_shared_bands(nir, ["nir", "swir1"])
499
+ nir_ref, swir_ref = resolved["nir"], resolved["swir1"]
500
+
260
501
  out_path = _resolve_output(Path(nir), output, "ndmi")
261
- self._save(result, profile, out_path)
502
+ if chunked:
503
+ chunked_index_compute(
504
+ inputs=[nir_ref, swir_ref],
505
+ formula=lambda B: (B[0] - B[1]) / (B[0] + B[1]),
506
+ output=out_path, tile_size=tile_size,
507
+ )
508
+ else:
509
+ nir_band = nir_ref[1] if isinstance(nir_ref, tuple) else 1
510
+ swir_band = swir_ref[1] if isinstance(swir_ref, tuple) else 1
511
+ nir_d, profile, _ = self._read(nir, band=nir_band)
512
+ swir_d, _, _ = self._read(swir1, ref_shape=nir_d.shape, band=swir_band)
513
+ result = self._norm_diff(nir_d, swir_d)
514
+ self._save(result, profile, out_path)
262
515
  return ProcessingResult(
263
516
  success=True, operation="ndmi", output_path=out_path, input_path=Path(nir)
264
517
  )
@@ -271,17 +524,39 @@ class SpectralIndices:
271
524
  nir: str | Path,
272
525
  swir2: str | Path,
273
526
  output: str | None = None,
527
+ chunked: bool = False,
528
+ tile_size: int = 1024,
274
529
  ) -> ProcessingResult:
275
530
  """
276
531
  NBR — Normalized Burn Ratio.
277
532
  Formula: (NIR - SWIR2) / (NIR + SWIR2).
278
533
  Use dNBR = pre_NBR - post_NBR for burn severity mapping.
534
+
535
+ Set chunked=True for large rasters that shouldn't be fully
536
+ loaded into memory at once (see ndvi's docstring for details).
537
+ If nir and swir2 point to the SAME merged multi-band image,
538
+ the correct bands are auto-detected from its real band
539
+ descriptions.
279
540
  """
280
- nir_d, profile, _ = self._read(nir)
281
- swir2_d, _, _ = self._read(swir2, ref_shape=nir_d.shape)
282
- result = self._norm_diff(nir_d, swir2_d)
541
+ nir_ref, swir_ref = nir, swir2
542
+ if str(nir) == str(swir2):
543
+ resolved = resolve_shared_bands(nir, ["nir", "swir2"])
544
+ nir_ref, swir_ref = resolved["nir"], resolved["swir2"]
545
+
283
546
  out_path = _resolve_output(Path(nir), output, "nbr")
284
- self._save(result, profile, out_path)
547
+ if chunked:
548
+ chunked_index_compute(
549
+ inputs=[nir_ref, swir_ref],
550
+ formula=lambda B: (B[0] - B[1]) / (B[0] + B[1]),
551
+ output=out_path, tile_size=tile_size,
552
+ )
553
+ else:
554
+ nir_band = nir_ref[1] if isinstance(nir_ref, tuple) else 1
555
+ swir_band = swir_ref[1] if isinstance(swir_ref, tuple) else 1
556
+ nir_d, profile, _ = self._read(nir, band=nir_band)
557
+ swir2_d, _, _ = self._read(swir2, ref_shape=nir_d.shape, band=swir_band)
558
+ result = self._norm_diff(nir_d, swir2_d)
559
+ self._save(result, profile, out_path)
285
560
  return ProcessingResult(
286
561
  success=True, operation="nbr", output_path=out_path, input_path=Path(nir)
287
562
  )
@@ -697,27 +972,49 @@ class SpectralIndices:
697
972
  inputs: list[str | Path],
698
973
  expression: str,
699
974
  output: str | None = None,
975
+ chunked: bool = False,
976
+ tile_size: int = 1024,
700
977
  ) -> ProcessingResult:
701
978
  """
702
979
  Arbitrary band arithmetic via a Python expression.
703
980
 
704
981
  Args:
705
- inputs: Raster paths; accessible in expression as B[0], B[1], …
982
+ inputs: Raster paths, OR (path, band_index) tuples to
983
+ reference a specific band within a shared,
984
+ merged multi-band image; accessible in
985
+ expression as B[0], B[1], …
706
986
  expression: E.g. ``"(B[1] - B[0]) / (B[1] + B[0] + 1e-6)"``
707
987
  output: Output path.
988
+ chunked: Set True for large rasters (e.g. drone
989
+ orthomosaics) that shouldn't be fully loaded
990
+ into memory at once, and that may exceed the
991
+ standard GeoTIFF 4GB size limit. Processes in
992
+ real, fixed-size tiles and writes a proper
993
+ tiled, BigTIFF output. Default False preserves
994
+ existing behavior for normal-sized rasters.
708
995
  """
709
996
  np = _require_numpy()
710
- B, profile, ref_shape = [], None, None
711
- for p in inputs:
712
- d, prof, _ = self._read(p, ref_shape=ref_shape)
713
- if profile is None:
714
- profile = prof
715
- ref_shape = d.shape
716
- B.append(d)
717
-
718
- result = eval(expression, {"B": B, "np": np}) # noqa: S307
719
- out_path = _resolve_output(Path(inputs[0]), output, "band_math")
720
- self._save(result, profile, out_path)
997
+ first_path = inputs[0][0] if isinstance(inputs[0], tuple) else inputs[0]
998
+ out_path = _resolve_output(Path(first_path), output, "band_math")
999
+
1000
+ if chunked:
1001
+ chunked_index_compute(
1002
+ inputs=inputs,
1003
+ formula=lambda B: eval(expression, {"B": B, "np": np}), # noqa: S307
1004
+ output=out_path, tile_size=tile_size,
1005
+ )
1006
+ else:
1007
+ B, profile, ref_shape = [], None, None
1008
+ for p in inputs:
1009
+ path, band = (p[0], p[1]) if isinstance(p, tuple) else (p, 1)
1010
+ d, prof, _ = self._read(path, ref_shape=ref_shape, band=band)
1011
+ if profile is None:
1012
+ profile = prof
1013
+ ref_shape = d.shape
1014
+ B.append(d)
1015
+
1016
+ result = eval(expression, {"B": B, "np": np}) # noqa: S307
1017
+ self._save(result, profile, out_path)
721
1018
  return ProcessingResult(
722
1019
  success=True,
723
1020
  operation="band_math",
@@ -760,4 +1057,4 @@ class SpectralIndices:
760
1057
  operation="stack",
761
1058
  output_path=out_path,
762
1059
  metadata={"n_bands": len(bands)},
763
- )
1060
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeofetch
3
- Version: 2.6.2.7
3
+ Version: 2.6.2.9
4
4
  Summary: Universal satellite data pipeline + geospatial processing platform
5
5
  Author-email: Samuel Appiah Kubi <appiahkubis14@gmail.com>
6
6
  License: MIT License
@@ -167,13 +167,13 @@ Where pygeofetch goes further than most data-access libraries is in its **InSAR
167
167
 
168
168
  | | |
169
169
  |---|---|
170
- | 🔐 **Authenticated access** | 22 verified providers; credentials stored via a Fernet-encrypted local file by default, or the system keyring (Keychain / Credential Manager / Secret Service) as an opt-in alternative |
171
- | 🔍 **Federated search** | One query across all providers → STAC 1.0 GeoJSON / GeoParquet / CSV, with real footprint geometry where the provider supplies it |
172
- | 📥 **Resilient downloads** | Parallel, resumable, checksum-verified, band-selective, atomic writes |
173
- | ⚙️ **Preprocessing** | Atmospheric correction, cloud masking, reprojection, resampling, pan-sharpening, mosaicking |
174
- | 📊 **17 spectral indices** | NDVI, EVI, SAVI, NDWI, MNDWI, NDBI, LST, Albedo, dNBR, GLCM texture, and more |
175
- | 🌐 **Verified InSAR chain** | Coregistration → phase removal → atmospheric/ionospheric correction → unwrapping → SBAS inversion |
176
- | 📋 **YAML pipelines** | Chainable, schedulable, repeatable workflows with full run history |
170
+ | **Authenticated access** | 22 verified providers; credentials stored via a Fernet-encrypted local file by default, or the system keyring (Keychain / Credential Manager / Secret Service) as an opt-in alternative |
171
+ | **Federated search** | One query across all providers → STAC 1.0 GeoJSON / GeoParquet / CSV, with real footprint geometry where the provider supplies it |
172
+ | **Resilient downloads** | Parallel, resumable, checksum-verified, band-selective, atomic writes |
173
+ | **Preprocessing** | Atmospheric correction, cloud masking, reprojection, resampling, pan-sharpening, mosaicking |
174
+ | **17 spectral indices** | NDVI, EVI, SAVI, NDWI, MNDWI, NDBI, LST, Albedo, dNBR, GLCM texture, and more |
175
+ | **Verified InSAR chain** | Coregistration → phase removal → atmospheric/ionospheric correction → unwrapping → SBAS inversion |
176
+ | **YAML pipelines** | Chainable, schedulable, repeatable workflows with full run history |
177
177
 
178
178
  ---
179
179
 
@@ -369,7 +369,7 @@ If you use PyGeoFetch in your research or operational work, please cite it using
369
369
  title = {PyGeoFetch: A Unified Python Framework for Multi-Provider Satellite Data Acquisition, Pre-download Quality Control, and Geospatial Processing},
370
370
  year = {2026},
371
371
  publisher = {Zenodo},
372
- version = {2.6.2.7.1},
372
+ version = {2.6.2.9.1},
373
373
  doi = {10.5281/zenodo.22087230},
374
374
  url = {https://doi.org/10.5281/zenodo.22087230}
375
375
  }
@@ -1,10 +1,10 @@
1
- pygeofetch/__init__.py,sha256=ynNVI54vEcWsdnY_aq4rV7o_7UX8b9GNOyqw6gcqc4I,1994
2
- pygeofetch/meta.yaml,sha256=JY5J_BlfrCgQeL1KFTd2NsgsW9_Vvw0TEJexkYh4Bks,1284
1
+ pygeofetch/__init__.py,sha256=V4sFNFNv4wEcBniS22-P6r0TQLdLD3YzXHFY4bVLe_c,1994
2
+ pygeofetch/meta.yaml,sha256=ebEXC87Hk17xlc1pdXV6vrbWUUXT0j69H0zoCESv-V8,1284
3
3
  pygeofetch/cli/__init__.py,sha256=QZMIIHEgmjGFieucqq9fZ5uephxP9-jdu1s8Q4qqw8o,30
4
4
  pygeofetch/cli/auth_commands.py,sha256=OUYbInPaERvAfNZ_NmAF3azHoJm14cGn_ZTQtxjjcs4,7871
5
5
  pygeofetch/cli/config_commands.py,sha256=1Pps0HLCLPODMlM6SFV8ru3IKCDpexSTz8uKIXvIIF8,3918
6
6
  pygeofetch/cli/download_commands.py,sha256=0xrp7XLOZIgGAwV79IBhusdIzv8RJAedSWjuz3WCazA,13866
7
- pygeofetch/cli/index_commands.py,sha256=IoLGrsSU-lTcBitFFsg8EtucqSKZtNL-0ODj_I8Twew,10020
7
+ pygeofetch/cli/index_commands.py,sha256=dbv3n0HcneMNxuIPVBXvioLPms2P5DoVgfmYHYnB1CI,14776
8
8
  pygeofetch/cli/main.py,sha256=FajFcdbeOKEabbIO3TqvChFVhiXcfZA6R1zdzu_oUfk,30565
9
9
  pygeofetch/cli/monitor_commands.py,sha256=lz-P8tQqymQnZyb0ndLoTLHhTZrIwfQ6khk4hb12Z6I,4521
10
10
  pygeofetch/cli/multisensor_commands.py,sha256=K6GNYqN7BLRhjOJAxfjpInjM_-ukEof8e3ekY3hr44o,8191
@@ -36,7 +36,7 @@ pygeofetch/insar/advanced_safeguards.py,sha256=CDMmxyC4PsldTaVXrxYm9vaJ2HS0izICB
36
36
  pygeofetch/insar/analysis.py,sha256=GLNNE2OY17Y92p4FJSu7DwitRRp6AFLj1hccSmw3Mqg,35770
37
37
  pygeofetch/insar/annotation.py,sha256=NRtH9HTlMQX1WkYLiJS9H7NrBxmUDBYIsZUOWVXZ37M,20775
38
38
  pygeofetch/insar/atmosphere.py,sha256=frOTzYxIz33L4vPxbCQ4fVSkY2c_BZqHwIUoi4grK2Y,23032
39
- pygeofetch/insar/auto_fallback.py,sha256=WRg-3pBIOFCFUK8BM_n2bZZAyvN1IwRLaEIU-1aIGEw,13652
39
+ pygeofetch/insar/auto_fallback.py,sha256=00tn6WT_w38vzv--bQvTWPKw2HRdo1UrWXhxpM7OGTE,14589
40
40
  pygeofetch/insar/coregister.py,sha256=9QILqYi6quuRsnqZr4K3Feyrkp-SN_ZOO3ypAgP4lJI,57924
41
41
  pygeofetch/insar/deburst.py,sha256=liWkKoP-UYYRRXxVYjsm2dpNCnIamL0RfH9JAe-660w,6456
42
42
  pygeofetch/insar/esd.py,sha256=FR0k7TwIUGInDA1u9cW4zrrX0HGTY5MuclfeDcShL9k,26177
@@ -73,9 +73,9 @@ pygeofetch/multisensor/vegetation_disturbance.py,sha256=jVUSxuFpw-Ipz0-JZ6sYTE16
73
73
  pygeofetch/optical/__init__.py,sha256=th071GPjYERZmNAU5Giz8p0ax8B4AyRlUwuO2-suxM8,1300
74
74
  pygeofetch/optical/offset_tracking.py,sha256=fPwaBhM6X_w3tXPLskpUD_W9VaUR3ORSuFsm1Sq_g5k,34881
75
75
  pygeofetch/processing/__init__.py,sha256=AP7MA88jXaJJDwwdjtDN1FX0f7WlSHyn__RlaFoQMH0,624
76
- pygeofetch/processing/base.py,sha256=C1a-NipnnjDcub2vPx-va0p0Q5cMuFmKIdnnkGkVC7o,12780
76
+ pygeofetch/processing/base.py,sha256=S2mvFUXNhCTLpJOmwhVJgK804BvFsHuDWFkZMvnVAzw,21828
77
77
  pygeofetch/processing/batch.py,sha256=_cOy7Nu7JCQKTwZLdwhwTPxJ7OzcKN4iQhKLDc7shOI,6024
78
- pygeofetch/processing/indices.py,sha256=bQe5PN7GlpBqUxe2-buV8QmfDvvdnouVgt855uCyjH4,29098
78
+ pygeofetch/processing/indices.py,sha256=rBpeRnuk5jN0vYZU4W7Pj3eO2iyDIS2ILdE62lKzuRc,42991
79
79
  pygeofetch/processing/pipeline.py,sha256=wKlP9bkZXmgaWtCHL8ADS-jUhg-xGjbPIn6cY3WGIeg,15798
80
80
  pygeofetch/processing/postprocessor.py,sha256=Ardwxy4ed3tfCKvwT64WmsDEJq6aUiuEiIxWqyLTp78,22330
81
81
  pygeofetch/processing/preprocessor.py,sha256=VNL_XnGc6NLalZcXjuOCuvriEZVpINewcx9NBd78vqE,72641
@@ -137,9 +137,9 @@ pygeofetch/validation/optical_validator.py,sha256=S0D361GibQwA2vvGb9_H8_6mxvaFKD
137
137
  pygeofetch/viz/__init__.py,sha256=xf2C9IL4n3bv3oxk8RYkogq7nkuNyt18V-urAoXWRQY,449
138
138
  pygeofetch/viz/map.py,sha256=f8whny5mnz7gDsStNdgtVSpqTNHSDhSZoRT2Qs-Lvos,16403
139
139
  pygeofetch/viz/plot.py,sha256=JybD4qElJBOTvWCu7VllL5DqkKFYf5ttJdFfsgApfLE,56601
140
- pygeofetch-2.6.2.7.dist-info/licenses/LICENSE,sha256=zavMyhODD6NvZTmmKFEtATrngwY4CU_NEtY7lhjxGnI,1075
141
- pygeofetch-2.6.2.7.dist-info/METADATA,sha256=5lI5ucqkUNx5MaIdn_hoQLYW4s1vshn3W1w3LSPr9kc,25010
142
- pygeofetch-2.6.2.7.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
143
- pygeofetch-2.6.2.7.dist-info/entry_points.txt,sha256=dHiUXLF5xNJ8vgtXZoUQNleEMQUeDPZiKQOE1tW8SDY,55
144
- pygeofetch-2.6.2.7.dist-info/top_level.txt,sha256=rOmnZargIfNk99fwVOqt6OJyJvpSTBBAYO5HU37yhHk,11
145
- pygeofetch-2.6.2.7.dist-info/RECORD,,
140
+ pygeofetch-2.6.2.9.dist-info/licenses/LICENSE,sha256=zavMyhODD6NvZTmmKFEtATrngwY4CU_NEtY7lhjxGnI,1075
141
+ pygeofetch-2.6.2.9.dist-info/METADATA,sha256=2abMqDD399UoNXMfFDSxmVT8EkjXDH0mhp4tXjJ0_zk,24973
142
+ pygeofetch-2.6.2.9.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
143
+ pygeofetch-2.6.2.9.dist-info/entry_points.txt,sha256=dHiUXLF5xNJ8vgtXZoUQNleEMQUeDPZiKQOE1tW8SDY,55
144
+ pygeofetch-2.6.2.9.dist-info/top_level.txt,sha256=rOmnZargIfNk99fwVOqt6OJyJvpSTBBAYO5HU37yhHk,11
145
+ pygeofetch-2.6.2.9.dist-info/RECORD,,