geomind-ai 1.0.1__py3-none-any.whl → 1.0.2__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.
@@ -1,347 +1,351 @@
1
- """
2
- Image processing tools for Sentinel-2 data.
3
-
4
- Handles loading Zarr data, applying corrections, and creating visualizations.
5
- """
6
-
7
- from typing import Optional, List, Tuple
8
- from pathlib import Path
9
- import numpy as np
10
-
11
- from ..config import (
12
- REFLECTANCE_SCALE,
13
- REFLECTANCE_OFFSET,
14
- RGB_BANDS,
15
- OUTPUT_DIR,
16
- )
17
-
18
-
19
- def _apply_scale_offset(
20
- data: np.ndarray,
21
- scale: float = REFLECTANCE_SCALE,
22
- offset: float = REFLECTANCE_OFFSET,
23
- nodata: int = 0,
24
- ) -> np.ndarray:
25
- """
26
- Apply scale and offset to convert DN to surface reflectance.
27
-
28
- Formula: reflectance = (DN * scale) + offset
29
-
30
- Args:
31
- data: Raw digital number values
32
- scale: Scale factor (default: 0.0001)
33
- offset: Offset value (default: -0.1)
34
- nodata: NoData value to mask (default: 0)
35
-
36
- Returns:
37
- Surface reflectance values
38
- """
39
- # Create mask for nodata
40
- mask = data == nodata
41
-
42
- # Apply transformation
43
- result = (data.astype(np.float32) * scale) + offset
44
-
45
- # Set nodata pixels to NaN
46
- result[mask] = np.nan
47
-
48
- return result
49
-
50
-
51
- def _normalize_for_display(
52
- data: np.ndarray,
53
- percentile_low: float = 2,
54
- percentile_high: float = 98,
55
- ) -> np.ndarray:
56
- """
57
- Normalize data to 0-1 range for display using percentile stretch.
58
-
59
- Args:
60
- data: Input array
61
- percentile_low: Lower percentile for clipping
62
- percentile_high: Upper percentile for clipping
63
-
64
- Returns:
65
- Normalized array in 0-1 range
66
- """
67
- # Get valid (non-NaN) values
68
- valid = data[~np.isnan(data)]
69
-
70
- if len(valid) == 0:
71
- return np.zeros_like(data)
72
-
73
- # Calculate percentiles
74
- low = np.percentile(valid, percentile_low)
75
- high = np.percentile(valid, percentile_high)
76
-
77
- # Normalize
78
- if high > low:
79
- result = (data - low) / (high - low)
80
- else:
81
- result = np.zeros_like(data)
82
-
83
- # Clip to 0-1
84
- result = np.clip(result, 0, 1)
85
-
86
- # Set NaN to 0 for display
87
- result = np.nan_to_num(result, nan=0)
88
-
89
- return result
90
-
91
-
92
- def create_rgb_composite(
93
- zarr_url: str,
94
- output_path: Optional[str] = None,
95
- subset_size: Optional[int] = 1000,
96
- ) -> dict:
97
- """
98
- Create an RGB composite image from Sentinel-2 10m bands.
99
-
100
- Uses B04 (Red), B03 (Green), B02 (Blue) bands.
101
-
102
- Args:
103
- zarr_url: URL to the SR_10m Zarr asset
104
- output_path: Optional path to save the image
105
- subset_size: Size to subset the image (for faster processing)
106
-
107
- Returns:
108
- Dictionary with path to saved image and metadata
109
- """
110
- try:
111
- import xarray as xr
112
- import matplotlib.pyplot as plt
113
- import zarr
114
-
115
- # Open the Zarr store
116
- # The SR_10m asset contains b02, b03, b04, b08
117
- store = zarr.open(zarr_url, mode='r')
118
-
119
- # Read the bands
120
- # Note: Band names are lowercase in the Zarr structure
121
- red = np.array(store['b04'])
122
- green = np.array(store['b03'])
123
- blue = np.array(store['b02'])
124
-
125
- # Subset if requested (for faster processing)
126
- if subset_size and red.shape[0] > subset_size:
127
- # Take center subset
128
- h, w = red.shape
129
- start_h = (h - subset_size) // 2
130
- start_w = (w - subset_size) // 2
131
- red = red[start_h:start_h+subset_size, start_w:start_w+subset_size]
132
- green = green[start_h:start_h+subset_size, start_w:start_w+subset_size]
133
- blue = blue[start_h:start_h+subset_size, start_w:start_w+subset_size]
134
-
135
- # Apply scale and offset
136
- red = _apply_scale_offset(red)
137
- green = _apply_scale_offset(green)
138
- blue = _apply_scale_offset(blue)
139
-
140
- # Normalize for display
141
- red = _normalize_for_display(red)
142
- green = _normalize_for_display(green)
143
- blue = _normalize_for_display(blue)
144
-
145
- # Stack into RGB
146
- rgb = np.dstack([red, green, blue])
147
-
148
- # Generate output path
149
- if output_path is None:
150
- output_path = OUTPUT_DIR / f"rgb_composite_{np.random.randint(10000)}.png"
151
- else:
152
- output_path = Path(output_path)
153
-
154
- # Create figure
155
- fig, ax = plt.subplots(figsize=(10, 10))
156
- ax.imshow(rgb)
157
- ax.set_title("Sentinel-2 RGB Composite (B4/B3/B2)")
158
- ax.axis('off')
159
-
160
- # Save
161
- plt.savefig(output_path, dpi=150, bbox_inches='tight', pad_inches=0.1)
162
- plt.close(fig)
163
-
164
- return {
165
- "success": True,
166
- "output_path": str(output_path),
167
- "image_size": rgb.shape[:2],
168
- "bands_used": ["B04 (Red)", "B03 (Green)", "B02 (Blue)"],
169
- }
170
-
171
- except Exception as e:
172
- return {
173
- "success": False,
174
- "error": str(e),
175
- }
176
-
177
-
178
- def calculate_ndvi(
179
- zarr_url: str,
180
- output_path: Optional[str] = None,
181
- subset_size: Optional[int] = 1000,
182
- ) -> dict:
183
- """
184
- Calculate NDVI (Normalized Difference Vegetation Index) from Sentinel-2 data.
185
-
186
- NDVI = (NIR - Red) / (NIR + Red)
187
- Uses B08 (NIR) and B04 (Red) bands.
188
-
189
- Args:
190
- zarr_url: URL to the SR_10m Zarr asset
191
- output_path: Optional path to save the NDVI image
192
- subset_size: Size to subset the image
193
-
194
- Returns:
195
- Dictionary with NDVI statistics and output path
196
- """
197
- try:
198
- import zarr
199
- import matplotlib.pyplot as plt
200
- from matplotlib.colors import LinearSegmentedColormap
201
-
202
- # Open the Zarr store
203
- store = zarr.open(zarr_url, mode='r')
204
-
205
- # Read the bands
206
- nir = np.array(store['b08']) # NIR
207
- red = np.array(store['b04']) # Red
208
-
209
- # Subset if requested
210
- if subset_size and nir.shape[0] > subset_size:
211
- h, w = nir.shape
212
- start_h = (h - subset_size) // 2
213
- start_w = (w - subset_size) // 2
214
- nir = nir[start_h:start_h+subset_size, start_w:start_w+subset_size]
215
- red = red[start_h:start_h+subset_size, start_w:start_w+subset_size]
216
-
217
- # Apply scale and offset
218
- nir = _apply_scale_offset(nir)
219
- red = _apply_scale_offset(red)
220
-
221
- # Calculate NDVI
222
- # Avoid division by zero
223
- denominator = nir + red
224
- denominator[denominator == 0] = np.nan
225
-
226
- ndvi = (nir - red) / denominator
227
-
228
- # NDVI statistics
229
- valid_ndvi = ndvi[~np.isnan(ndvi)]
230
- stats = {
231
- "min": float(np.min(valid_ndvi)) if len(valid_ndvi) > 0 else None,
232
- "max": float(np.max(valid_ndvi)) if len(valid_ndvi) > 0 else None,
233
- "mean": float(np.mean(valid_ndvi)) if len(valid_ndvi) > 0 else None,
234
- "std": float(np.std(valid_ndvi)) if len(valid_ndvi) > 0 else None,
235
- }
236
-
237
- # Generate output path
238
- if output_path is None:
239
- output_path = OUTPUT_DIR / f"ndvi_{np.random.randint(10000)}.png"
240
- else:
241
- output_path = Path(output_path)
242
-
243
- # Create NDVI colormap (brown -> yellow -> green)
244
- colors = ['#8B4513', '#D2691E', '#FFD700', '#ADFF2F', '#228B22', '#006400']
245
- ndvi_cmap = LinearSegmentedColormap.from_list('ndvi', colors)
246
-
247
- # Create figure
248
- fig, ax = plt.subplots(figsize=(10, 10))
249
- im = ax.imshow(ndvi, cmap=ndvi_cmap, vmin=-1, vmax=1)
250
- ax.set_title("NDVI - Normalized Difference Vegetation Index")
251
- ax.axis('off')
252
-
253
- # Add colorbar
254
- cbar = plt.colorbar(im, ax=ax, shrink=0.8)
255
- cbar.set_label('NDVI')
256
-
257
- # Save
258
- plt.savefig(output_path, dpi=150, bbox_inches='tight', pad_inches=0.1)
259
- plt.close(fig)
260
-
261
- return {
262
- "success": True,
263
- "output_path": str(output_path),
264
- "statistics": stats,
265
- "interpretation": _interpret_ndvi(stats["mean"]) if stats["mean"] else None,
266
- }
267
-
268
- except Exception as e:
269
- return {
270
- "success": False,
271
- "error": str(e),
272
- }
273
-
274
-
275
- def _interpret_ndvi(mean_ndvi: float) -> str:
276
- """Provide interpretation of mean NDVI value."""
277
- if mean_ndvi < 0:
278
- return "Water or bare surfaces dominant"
279
- elif mean_ndvi < 0.1:
280
- return "Bare soil or built-up areas"
281
- elif mean_ndvi < 0.2:
282
- return "Sparse vegetation or stressed plants"
283
- elif mean_ndvi < 0.4:
284
- return "Moderate vegetation"
285
- elif mean_ndvi < 0.6:
286
- return "Dense vegetation"
287
- else:
288
- return "Very dense/healthy vegetation"
289
-
290
-
291
- def get_band_statistics(
292
- zarr_url: str,
293
- bands: Optional[List[str]] = None,
294
- ) -> dict:
295
- """
296
- Get statistics for specified bands from a Sentinel-2 Zarr asset.
297
-
298
- Args:
299
- zarr_url: URL to the Zarr asset (e.g., SR_10m)
300
- bands: List of band names (default: all available)
301
-
302
- Returns:
303
- Dictionary with statistics for each band
304
- """
305
- try:
306
- import zarr
307
-
308
- store = zarr.open(zarr_url, mode='r')
309
-
310
- # Get available bands if not specified
311
- if bands is None:
312
- bands = [key for key in store.keys() if key.startswith('b')]
313
-
314
- results = {}
315
-
316
- for band in bands:
317
- if band not in store:
318
- results[band] = {"error": "Band not found"}
319
- continue
320
-
321
- data = np.array(store[band])
322
-
323
- # Apply scale/offset
324
- data = _apply_scale_offset(data)
325
- valid = data[~np.isnan(data)]
326
-
327
- if len(valid) > 0:
328
- results[band] = {
329
- "min": float(np.min(valid)),
330
- "max": float(np.max(valid)),
331
- "mean": float(np.mean(valid)),
332
- "std": float(np.std(valid)),
333
- "shape": data.shape,
334
- }
335
- else:
336
- results[band] = {"error": "No valid data"}
337
-
338
- return {
339
- "success": True,
340
- "band_statistics": results,
341
- }
342
-
343
- except Exception as e:
344
- return {
345
- "success": False,
346
- "error": str(e),
347
- }
1
+ """
2
+ Image processing tools for Sentinel-2 data.
3
+
4
+ Handles loading Zarr data, applying corrections, and creating visualizations.
5
+ """
6
+
7
+ from typing import Optional, List, Tuple
8
+ from pathlib import Path
9
+ import numpy as np
10
+
11
+ from ..config import (
12
+ REFLECTANCE_SCALE,
13
+ REFLECTANCE_OFFSET,
14
+ RGB_BANDS,
15
+ OUTPUT_DIR,
16
+ )
17
+
18
+
19
+ def _apply_scale_offset(
20
+ data: np.ndarray,
21
+ scale: float = REFLECTANCE_SCALE,
22
+ offset: float = REFLECTANCE_OFFSET,
23
+ nodata: int = 0,
24
+ ) -> np.ndarray:
25
+ """
26
+ Apply scale and offset to convert DN to surface reflectance.
27
+
28
+ Formula: reflectance = (DN * scale) + offset
29
+
30
+ Args:
31
+ data: Raw digital number values
32
+ scale: Scale factor (default: 0.0001)
33
+ offset: Offset value (default: -0.1)
34
+ nodata: NoData value to mask (default: 0)
35
+
36
+ Returns:
37
+ Surface reflectance values
38
+ """
39
+ # Create mask for nodata
40
+ mask = data == nodata
41
+
42
+ # Apply transformation
43
+ result = (data.astype(np.float32) * scale) + offset
44
+
45
+ # Set nodata pixels to NaN
46
+ result[mask] = np.nan
47
+
48
+ return result
49
+
50
+
51
+ def _normalize_for_display(
52
+ data: np.ndarray,
53
+ percentile_low: float = 2,
54
+ percentile_high: float = 98,
55
+ ) -> np.ndarray:
56
+ """
57
+ Normalize data to 0-1 range for display using percentile stretch.
58
+
59
+ Args:
60
+ data: Input array
61
+ percentile_low: Lower percentile for clipping
62
+ percentile_high: Upper percentile for clipping
63
+
64
+ Returns:
65
+ Normalized array in 0-1 range
66
+ """
67
+ # Get valid (non-NaN) values
68
+ valid = data[~np.isnan(data)]
69
+
70
+ if len(valid) == 0:
71
+ return np.zeros_like(data)
72
+
73
+ # Calculate percentiles
74
+ low = np.percentile(valid, percentile_low)
75
+ high = np.percentile(valid, percentile_high)
76
+
77
+ # Normalize
78
+ if high > low:
79
+ result = (data - low) / (high - low)
80
+ else:
81
+ result = np.zeros_like(data)
82
+
83
+ # Clip to 0-1
84
+ result = np.clip(result, 0, 1)
85
+
86
+ # Set NaN to 0 for display
87
+ result = np.nan_to_num(result, nan=0)
88
+
89
+ return result
90
+
91
+
92
+ def create_rgb_composite(
93
+ zarr_url: str,
94
+ output_path: Optional[str] = None,
95
+ subset_size: Optional[int] = 1000,
96
+ ) -> dict:
97
+ """
98
+ Create an RGB composite image from Sentinel-2 10m bands.
99
+
100
+ Uses B04 (Red), B03 (Green), B02 (Blue) bands.
101
+
102
+ Args:
103
+ zarr_url: URL to the SR_10m Zarr asset
104
+ output_path: Optional path to save the image
105
+ subset_size: Size to subset the image (for faster processing)
106
+
107
+ Returns:
108
+ Dictionary with path to saved image and metadata
109
+ """
110
+ try:
111
+ import xarray as xr
112
+ import matplotlib.pyplot as plt
113
+ import zarr
114
+
115
+ # Open the Zarr store
116
+ # The SR_10m asset contains b02, b03, b04, b08
117
+ store = zarr.open(zarr_url, mode="r")
118
+
119
+ # Read the bands
120
+ # Note: Band names are lowercase in the Zarr structure
121
+ red = np.array(store["b04"])
122
+ green = np.array(store["b03"])
123
+ blue = np.array(store["b02"])
124
+
125
+ # Subset if requested (for faster processing)
126
+ if subset_size and red.shape[0] > subset_size:
127
+ # Take center subset
128
+ h, w = red.shape
129
+ start_h = (h - subset_size) // 2
130
+ start_w = (w - subset_size) // 2
131
+ red = red[start_h : start_h + subset_size, start_w : start_w + subset_size]
132
+ green = green[
133
+ start_h : start_h + subset_size, start_w : start_w + subset_size
134
+ ]
135
+ blue = blue[
136
+ start_h : start_h + subset_size, start_w : start_w + subset_size
137
+ ]
138
+
139
+ # Apply scale and offset
140
+ red = _apply_scale_offset(red)
141
+ green = _apply_scale_offset(green)
142
+ blue = _apply_scale_offset(blue)
143
+
144
+ # Normalize for display
145
+ red = _normalize_for_display(red)
146
+ green = _normalize_for_display(green)
147
+ blue = _normalize_for_display(blue)
148
+
149
+ # Stack into RGB
150
+ rgb = np.dstack([red, green, blue])
151
+
152
+ # Generate output path
153
+ if output_path is None:
154
+ output_path = OUTPUT_DIR / f"rgb_composite_{np.random.randint(10000)}.png"
155
+ else:
156
+ output_path = Path(output_path)
157
+
158
+ # Create figure
159
+ fig, ax = plt.subplots(figsize=(10, 10))
160
+ ax.imshow(rgb)
161
+ ax.set_title("Sentinel-2 RGB Composite (B4/B3/B2)")
162
+ ax.axis("off")
163
+
164
+ # Save
165
+ plt.savefig(output_path, dpi=150, bbox_inches="tight", pad_inches=0.1)
166
+ plt.close(fig)
167
+
168
+ return {
169
+ "success": True,
170
+ "output_path": str(output_path),
171
+ "image_size": rgb.shape[:2],
172
+ "bands_used": ["B04 (Red)", "B03 (Green)", "B02 (Blue)"],
173
+ }
174
+
175
+ except Exception as e:
176
+ return {
177
+ "success": False,
178
+ "error": str(e),
179
+ }
180
+
181
+
182
+ def calculate_ndvi(
183
+ zarr_url: str,
184
+ output_path: Optional[str] = None,
185
+ subset_size: Optional[int] = 1000,
186
+ ) -> dict:
187
+ """
188
+ Calculate NDVI (Normalized Difference Vegetation Index) from Sentinel-2 data.
189
+
190
+ NDVI = (NIR - Red) / (NIR + Red)
191
+ Uses B08 (NIR) and B04 (Red) bands.
192
+
193
+ Args:
194
+ zarr_url: URL to the SR_10m Zarr asset
195
+ output_path: Optional path to save the NDVI image
196
+ subset_size: Size to subset the image
197
+
198
+ Returns:
199
+ Dictionary with NDVI statistics and output path
200
+ """
201
+ try:
202
+ import zarr
203
+ import matplotlib.pyplot as plt
204
+ from matplotlib.colors import LinearSegmentedColormap
205
+
206
+ # Open the Zarr store
207
+ store = zarr.open(zarr_url, mode="r")
208
+
209
+ # Read the bands
210
+ nir = np.array(store["b08"]) # NIR
211
+ red = np.array(store["b04"]) # Red
212
+
213
+ # Subset if requested
214
+ if subset_size and nir.shape[0] > subset_size:
215
+ h, w = nir.shape
216
+ start_h = (h - subset_size) // 2
217
+ start_w = (w - subset_size) // 2
218
+ nir = nir[start_h : start_h + subset_size, start_w : start_w + subset_size]
219
+ red = red[start_h : start_h + subset_size, start_w : start_w + subset_size]
220
+
221
+ # Apply scale and offset
222
+ nir = _apply_scale_offset(nir)
223
+ red = _apply_scale_offset(red)
224
+
225
+ # Calculate NDVI
226
+ # Avoid division by zero
227
+ denominator = nir + red
228
+ denominator[denominator == 0] = np.nan
229
+
230
+ ndvi = (nir - red) / denominator
231
+
232
+ # NDVI statistics
233
+ valid_ndvi = ndvi[~np.isnan(ndvi)]
234
+ stats = {
235
+ "min": float(np.min(valid_ndvi)) if len(valid_ndvi) > 0 else None,
236
+ "max": float(np.max(valid_ndvi)) if len(valid_ndvi) > 0 else None,
237
+ "mean": float(np.mean(valid_ndvi)) if len(valid_ndvi) > 0 else None,
238
+ "std": float(np.std(valid_ndvi)) if len(valid_ndvi) > 0 else None,
239
+ }
240
+
241
+ # Generate output path
242
+ if output_path is None:
243
+ output_path = OUTPUT_DIR / f"ndvi_{np.random.randint(10000)}.png"
244
+ else:
245
+ output_path = Path(output_path)
246
+
247
+ # Create NDVI colormap (brown -> yellow -> green)
248
+ colors = ["#8B4513", "#D2691E", "#FFD700", "#ADFF2F", "#228B22", "#006400"]
249
+ ndvi_cmap = LinearSegmentedColormap.from_list("ndvi", colors)
250
+
251
+ # Create figure
252
+ fig, ax = plt.subplots(figsize=(10, 10))
253
+ im = ax.imshow(ndvi, cmap=ndvi_cmap, vmin=-1, vmax=1)
254
+ ax.set_title("NDVI - Normalized Difference Vegetation Index")
255
+ ax.axis("off")
256
+
257
+ # Add colorbar
258
+ cbar = plt.colorbar(im, ax=ax, shrink=0.8)
259
+ cbar.set_label("NDVI")
260
+
261
+ # Save
262
+ plt.savefig(output_path, dpi=150, bbox_inches="tight", pad_inches=0.1)
263
+ plt.close(fig)
264
+
265
+ return {
266
+ "success": True,
267
+ "output_path": str(output_path),
268
+ "statistics": stats,
269
+ "interpretation": _interpret_ndvi(stats["mean"]) if stats["mean"] else None,
270
+ }
271
+
272
+ except Exception as e:
273
+ return {
274
+ "success": False,
275
+ "error": str(e),
276
+ }
277
+
278
+
279
+ def _interpret_ndvi(mean_ndvi: float) -> str:
280
+ """Provide interpretation of mean NDVI value."""
281
+ if mean_ndvi < 0:
282
+ return "Water or bare surfaces dominant"
283
+ elif mean_ndvi < 0.1:
284
+ return "Bare soil or built-up areas"
285
+ elif mean_ndvi < 0.2:
286
+ return "Sparse vegetation or stressed plants"
287
+ elif mean_ndvi < 0.4:
288
+ return "Moderate vegetation"
289
+ elif mean_ndvi < 0.6:
290
+ return "Dense vegetation"
291
+ else:
292
+ return "Very dense/healthy vegetation"
293
+
294
+
295
+ def get_band_statistics(
296
+ zarr_url: str,
297
+ bands: Optional[List[str]] = None,
298
+ ) -> dict:
299
+ """
300
+ Get statistics for specified bands from a Sentinel-2 Zarr asset.
301
+
302
+ Args:
303
+ zarr_url: URL to the Zarr asset (e.g., SR_10m)
304
+ bands: List of band names (default: all available)
305
+
306
+ Returns:
307
+ Dictionary with statistics for each band
308
+ """
309
+ try:
310
+ import zarr
311
+
312
+ store = zarr.open(zarr_url, mode="r")
313
+
314
+ # Get available bands if not specified
315
+ if bands is None:
316
+ bands = [key for key in store.keys() if key.startswith("b")]
317
+
318
+ results = {}
319
+
320
+ for band in bands:
321
+ if band not in store:
322
+ results[band] = {"error": "Band not found"}
323
+ continue
324
+
325
+ data = np.array(store[band])
326
+
327
+ # Apply scale/offset
328
+ data = _apply_scale_offset(data)
329
+ valid = data[~np.isnan(data)]
330
+
331
+ if len(valid) > 0:
332
+ results[band] = {
333
+ "min": float(np.min(valid)),
334
+ "max": float(np.max(valid)),
335
+ "mean": float(np.mean(valid)),
336
+ "std": float(np.std(valid)),
337
+ "shape": data.shape,
338
+ }
339
+ else:
340
+ results[band] = {"error": "No valid data"}
341
+
342
+ return {
343
+ "success": True,
344
+ "band_statistics": results,
345
+ }
346
+
347
+ except Exception as e:
348
+ return {
349
+ "success": False,
350
+ "error": str(e),
351
+ }