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