geomind-ai 1.1.0__py3-none-any.whl → 1.2.0__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.
- geomind/__init__.py +1 -1
- geomind/agent.py +110 -349
- geomind/cli.py +215 -22
- geomind/config.py +3 -3
- geomind/tools/processing.py +90 -38
- geomind/tools/stac_search.py +61 -11
- {geomind_ai-1.1.0.dist-info → geomind_ai-1.2.0.dist-info}/METADATA +1 -1
- geomind_ai-1.2.0.dist-info/RECORD +14 -0
- {geomind_ai-1.1.0.dist-info → geomind_ai-1.2.0.dist-info}/WHEEL +1 -1
- geomind_ai-1.1.0.dist-info/RECORD +0 -14
- {geomind_ai-1.1.0.dist-info → geomind_ai-1.2.0.dist-info}/entry_points.txt +0 -0
- {geomind_ai-1.1.0.dist-info → geomind_ai-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {geomind_ai-1.1.0.dist-info → geomind_ai-1.2.0.dist-info}/top_level.txt +0 -0
geomind/__init__.py
CHANGED
geomind/agent.py
CHANGED
|
@@ -1,144 +1,50 @@
|
|
|
1
1
|
"""
|
|
2
|
-
GeoMind Agent -
|
|
3
|
-
|
|
4
|
-
This module implements the AI agent that can understand natural language
|
|
5
|
-
queries about satellite imagery and execute the appropriate tools.
|
|
2
|
+
GeoMind Agent - Simplified AI agent for satellite imagery analysis.
|
|
6
3
|
"""
|
|
7
4
|
|
|
8
5
|
import json
|
|
9
|
-
import
|
|
10
|
-
from typing import Optional, Callable, Any
|
|
6
|
+
from typing import Optional
|
|
11
7
|
from datetime import datetime
|
|
12
|
-
|
|
13
8
|
from openai import OpenAI
|
|
14
9
|
|
|
15
|
-
from .config import
|
|
16
|
-
OPENROUTER_API_KEY,
|
|
17
|
-
OPENROUTER_API_URL,
|
|
18
|
-
OPENROUTER_MODEL,
|
|
19
|
-
)
|
|
10
|
+
from .config import OPENROUTER_API_KEY, OPENROUTER_API_URL, OPENROUTER_MODEL
|
|
20
11
|
from .tools import (
|
|
21
|
-
geocode_location,
|
|
22
|
-
get_bbox_from_location,
|
|
23
|
-
search_imagery,
|
|
24
|
-
get_item_details,
|
|
25
12
|
list_recent_imagery,
|
|
26
13
|
create_rgb_composite,
|
|
27
14
|
calculate_ndvi,
|
|
15
|
+
search_imagery,
|
|
16
|
+
get_bbox_from_location,
|
|
17
|
+
geocode_location,
|
|
18
|
+
get_item_details,
|
|
28
19
|
get_band_statistics,
|
|
29
20
|
)
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
# Map tool names to functions
|
|
22
|
+
# Tool function mapping
|
|
33
23
|
TOOL_FUNCTIONS = {
|
|
34
|
-
"geocode_location": geocode_location,
|
|
35
|
-
"get_bbox_from_location": get_bbox_from_location,
|
|
36
|
-
"search_imagery": search_imagery,
|
|
37
24
|
"list_recent_imagery": list_recent_imagery,
|
|
38
|
-
"get_item_details": get_item_details,
|
|
39
25
|
"create_rgb_composite": create_rgb_composite,
|
|
40
26
|
"calculate_ndvi": calculate_ndvi,
|
|
27
|
+
"search_imagery": search_imagery,
|
|
28
|
+
"get_bbox_from_location": get_bbox_from_location,
|
|
29
|
+
"geocode_location": geocode_location,
|
|
30
|
+
"get_item_details": get_item_details,
|
|
41
31
|
"get_band_statistics": get_band_statistics,
|
|
42
32
|
}
|
|
43
33
|
|
|
44
|
-
#
|
|
34
|
+
# Simplified tool definitions
|
|
45
35
|
TOOLS = [
|
|
46
|
-
{
|
|
47
|
-
"type": "function",
|
|
48
|
-
"function": {
|
|
49
|
-
"name": "geocode_location",
|
|
50
|
-
"description": "Convert a place name to geographic coordinates (latitude, longitude). Use this when you need to find coordinates for a location.",
|
|
51
|
-
"parameters": {
|
|
52
|
-
"type": "object",
|
|
53
|
-
"properties": {
|
|
54
|
-
"place_name": {
|
|
55
|
-
"type": "string",
|
|
56
|
-
"description": "The name of the place to geocode (e.g., 'New York City', 'Paris, France')",
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
"required": ["place_name"],
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
"type": "function",
|
|
65
|
-
"function": {
|
|
66
|
-
"name": "get_bbox_from_location",
|
|
67
|
-
"description": "Get a bounding box for a location, suitable for searching satellite imagery.",
|
|
68
|
-
"parameters": {
|
|
69
|
-
"type": "object",
|
|
70
|
-
"properties": {
|
|
71
|
-
"place_name": {
|
|
72
|
-
"type": "string",
|
|
73
|
-
"description": "The name of the place",
|
|
74
|
-
},
|
|
75
|
-
"buffer_km": {
|
|
76
|
-
"type": "number",
|
|
77
|
-
"description": "Buffer distance in kilometers (default: 10)",
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
"required": ["place_name"],
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
"type": "function",
|
|
86
|
-
"function": {
|
|
87
|
-
"name": "search_imagery",
|
|
88
|
-
"description": "Search for Sentinel-2 satellite imagery in the EOPF catalog. Returns available scenes.",
|
|
89
|
-
"parameters": {
|
|
90
|
-
"type": "object",
|
|
91
|
-
"properties": {
|
|
92
|
-
"bbox": {
|
|
93
|
-
"type": "array",
|
|
94
|
-
"items": {"type": "number"},
|
|
95
|
-
"description": "Bounding box as [min_lon, min_lat, max_lon, max_lat]",
|
|
96
|
-
},
|
|
97
|
-
"start_date": {
|
|
98
|
-
"type": "string",
|
|
99
|
-
"description": "Start date in YYYY-MM-DD format",
|
|
100
|
-
},
|
|
101
|
-
"end_date": {
|
|
102
|
-
"type": "string",
|
|
103
|
-
"description": "End date in YYYY-MM-DD format",
|
|
104
|
-
},
|
|
105
|
-
"max_cloud_cover": {
|
|
106
|
-
"type": "number",
|
|
107
|
-
"description": "Maximum cloud cover percentage (0-100)",
|
|
108
|
-
},
|
|
109
|
-
"max_items": {
|
|
110
|
-
"type": "integer",
|
|
111
|
-
"description": "Maximum number of results",
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
"required": [],
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
},
|
|
118
36
|
{
|
|
119
37
|
"type": "function",
|
|
120
38
|
"function": {
|
|
121
39
|
"name": "list_recent_imagery",
|
|
122
|
-
"description": "
|
|
40
|
+
"description": "Find recent Sentinel-2 imagery for a location",
|
|
123
41
|
"parameters": {
|
|
124
42
|
"type": "object",
|
|
125
43
|
"properties": {
|
|
126
|
-
"location_name": {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
},
|
|
130
|
-
"days": {
|
|
131
|
-
"type": "integer",
|
|
132
|
-
"description": "Number of days to look back (default: 7)",
|
|
133
|
-
},
|
|
134
|
-
"max_cloud_cover": {
|
|
135
|
-
"type": "number",
|
|
136
|
-
"description": "Maximum cloud cover percentage",
|
|
137
|
-
},
|
|
138
|
-
"max_items": {
|
|
139
|
-
"type": "integer",
|
|
140
|
-
"description": "Maximum number of results",
|
|
141
|
-
},
|
|
44
|
+
"location_name": {"type": "string", "description": "Location name"},
|
|
45
|
+
"days": {"type": "integer", "description": "Days to look back (default: 14)"},
|
|
46
|
+
"max_cloud_cover": {"type": "number", "description": "Max cloud cover %"},
|
|
47
|
+
"max_items": {"type": "integer", "description": "Max results"},
|
|
142
48
|
},
|
|
143
49
|
"required": [],
|
|
144
50
|
},
|
|
@@ -147,37 +53,30 @@ TOOLS = [
|
|
|
147
53
|
{
|
|
148
54
|
"type": "function",
|
|
149
55
|
"function": {
|
|
150
|
-
"name": "
|
|
151
|
-
"description": "
|
|
56
|
+
"name": "create_rgb_composite",
|
|
57
|
+
"description": "Create RGB composite from Sentinel-2 data",
|
|
152
58
|
"parameters": {
|
|
153
59
|
"type": "object",
|
|
154
60
|
"properties": {
|
|
155
|
-
"
|
|
61
|
+
"zarr_url": {"type": "string", "description": "SR_10m Zarr URL"},
|
|
62
|
+
"location_name": {"type": "string", "description": "Location for title"},
|
|
63
|
+
"subset_size": {"type": "integer", "description": "Image size (default: 1000)"},
|
|
156
64
|
},
|
|
157
|
-
"required": ["
|
|
65
|
+
"required": ["zarr_url"],
|
|
158
66
|
},
|
|
159
67
|
},
|
|
160
68
|
},
|
|
161
69
|
{
|
|
162
70
|
"type": "function",
|
|
163
71
|
"function": {
|
|
164
|
-
"name": "
|
|
165
|
-
"description": "
|
|
72
|
+
"name": "calculate_ndvi",
|
|
73
|
+
"description": "Calculate NDVI vegetation index",
|
|
166
74
|
"parameters": {
|
|
167
75
|
"type": "object",
|
|
168
76
|
"properties": {
|
|
169
|
-
"zarr_url": {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
},
|
|
173
|
-
"output_path": {
|
|
174
|
-
"type": "string",
|
|
175
|
-
"description": "Optional path to save the output image",
|
|
176
|
-
},
|
|
177
|
-
"subset_size": {
|
|
178
|
-
"type": "integer",
|
|
179
|
-
"description": "Size to subset the image (default: 1000 pixels)",
|
|
180
|
-
},
|
|
77
|
+
"zarr_url": {"type": "string", "description": "SR_10m Zarr URL"},
|
|
78
|
+
"location_name": {"type": "string", "description": "Location for title"},
|
|
79
|
+
"subset_size": {"type": "integer", "description": "Image size (default: 1000)"},
|
|
181
80
|
},
|
|
182
81
|
"required": ["zarr_url"],
|
|
183
82
|
},
|
|
@@ -186,47 +85,33 @@ TOOLS = [
|
|
|
186
85
|
{
|
|
187
86
|
"type": "function",
|
|
188
87
|
"function": {
|
|
189
|
-
"name": "
|
|
190
|
-
"description": "
|
|
88
|
+
"name": "search_imagery",
|
|
89
|
+
"description": "Search Sentinel-2 imagery by parameters",
|
|
191
90
|
"parameters": {
|
|
192
91
|
"type": "object",
|
|
193
92
|
"properties": {
|
|
194
|
-
"
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
},
|
|
198
|
-
"
|
|
199
|
-
"type": "string",
|
|
200
|
-
"description": "Optional path to save the NDVI image",
|
|
201
|
-
},
|
|
202
|
-
"subset_size": {
|
|
203
|
-
"type": "integer",
|
|
204
|
-
"description": "Size to subset the image",
|
|
205
|
-
},
|
|
93
|
+
"bbox": {"type": "array", "items": {"type": "number"}, "description": "Bounding box"},
|
|
94
|
+
"start_date": {"type": "string", "description": "Start date YYYY-MM-DD"},
|
|
95
|
+
"end_date": {"type": "string", "description": "End date YYYY-MM-DD"},
|
|
96
|
+
"max_cloud_cover": {"type": "number", "description": "Max cloud cover %"},
|
|
97
|
+
"max_items": {"type": "integer", "description": "Max results"},
|
|
206
98
|
},
|
|
207
|
-
"required": [
|
|
99
|
+
"required": [],
|
|
208
100
|
},
|
|
209
101
|
},
|
|
210
102
|
},
|
|
211
103
|
{
|
|
212
104
|
"type": "function",
|
|
213
105
|
"function": {
|
|
214
|
-
"name": "
|
|
215
|
-
"description": "Get
|
|
106
|
+
"name": "get_bbox_from_location",
|
|
107
|
+
"description": "Get bounding box for a location",
|
|
216
108
|
"parameters": {
|
|
217
109
|
"type": "object",
|
|
218
110
|
"properties": {
|
|
219
|
-
"
|
|
220
|
-
|
|
221
|
-
"description": "URL to the Zarr asset",
|
|
222
|
-
},
|
|
223
|
-
"bands": {
|
|
224
|
-
"type": "array",
|
|
225
|
-
"items": {"type": "string"},
|
|
226
|
-
"description": "List of band names to analyze",
|
|
227
|
-
},
|
|
111
|
+
"place_name": {"type": "string", "description": "Location name"},
|
|
112
|
+
"buffer_km": {"type": "number", "description": "Buffer distance in km"},
|
|
228
113
|
},
|
|
229
|
-
"required": ["
|
|
114
|
+
"required": ["place_name"],
|
|
230
115
|
},
|
|
231
116
|
},
|
|
232
117
|
},
|
|
@@ -234,224 +119,100 @@ TOOLS = [
|
|
|
234
119
|
|
|
235
120
|
|
|
236
121
|
class GeoMindAgent:
|
|
237
|
-
"""
|
|
238
|
-
GeoMind - An AI agent for geospatial analysis with Sentinel-2 imagery.
|
|
239
|
-
|
|
240
|
-
Uses OpenRouter API for access to multiple AI models.
|
|
241
|
-
"""
|
|
122
|
+
"""Simplified GeoMind agent for satellite imagery analysis."""
|
|
242
123
|
|
|
243
124
|
def __init__(self, model: Optional[str] = None, api_key: Optional[str] = None):
|
|
244
|
-
"""
|
|
245
|
-
Initialize the GeoMind agent.
|
|
246
|
-
|
|
247
|
-
Args:
|
|
248
|
-
model: Model name (default: nvidia/nemotron-3-nano-30b-a3b:free)
|
|
249
|
-
api_key: OpenRouter API key (required).
|
|
250
|
-
"""
|
|
251
|
-
self.provider = "openrouter"
|
|
252
125
|
self.api_key = api_key or OPENROUTER_API_KEY
|
|
253
126
|
self.model_name = model or OPENROUTER_MODEL
|
|
254
|
-
|
|
255
|
-
|
|
127
|
+
|
|
256
128
|
if not self.api_key:
|
|
257
|
-
raise ValueError(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
"1. Run: geomind --api-key YOUR_KEY\n"
|
|
262
|
-
"2. Set environment variable: OPENROUTER_API_KEY=YOUR_KEY\n"
|
|
263
|
-
"3. Create .env file with: OPENROUTER_API_KEY=YOUR_KEY"
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
print(f"🚀 GeoMind Agent initialized with {self.model_name}")
|
|
267
|
-
|
|
268
|
-
# Create OpenAI-compatible client
|
|
269
|
-
self.client = OpenAI(base_url=self.base_url, api_key=self.api_key)
|
|
270
|
-
|
|
271
|
-
# Chat history
|
|
129
|
+
raise ValueError("OpenRouter API key required")
|
|
130
|
+
|
|
131
|
+
print(f"GeoMind Agent initialized with {self.model_name}")
|
|
132
|
+
self.client = OpenAI(base_url=OPENROUTER_API_URL, api_key=self.api_key)
|
|
272
133
|
self.history = []
|
|
273
134
|
|
|
274
|
-
# Add system message
|
|
275
|
-
self.system_prompt = self._get_system_prompt()
|
|
276
|
-
|
|
277
135
|
def _get_system_prompt(self) -> str:
|
|
278
|
-
"""
|
|
279
|
-
return f"""You are GeoMind, an
|
|
280
|
-
and satellite imagery. You help users find, analyze, and visualize Sentinel-2 satellite data
|
|
281
|
-
from the EOPF (ESA Earth Observation Processing Framework) catalog.
|
|
282
|
-
|
|
283
|
-
Your capabilities include:
|
|
284
|
-
1. **Search**: Find Sentinel-2 L2A imagery by location, date, and cloud cover
|
|
285
|
-
2. **Geocoding**: Convert place names to coordinates for searching
|
|
286
|
-
3. **Visualization**: Create RGB composites and NDVI maps from imagery
|
|
287
|
-
4. **Analysis**: Calculate spectral indices and band statistics
|
|
288
|
-
|
|
289
|
-
Key information:
|
|
290
|
-
- Data source: EOPF STAC API (https://stac.core.eopf.eodc.eu)
|
|
291
|
-
- Satellite: Sentinel-2 (L2A surface reflectance products)
|
|
292
|
-
- Bands available: B01-B12 at 10m, 20m, or 60m resolution
|
|
293
|
-
- Current date: {datetime.now().strftime('%Y-%m-%d')}
|
|
294
|
-
|
|
295
|
-
When users ask for imagery:
|
|
296
|
-
1. First use get_bbox_from_location or list_recent_imagery to search
|
|
297
|
-
2. Present the results clearly with key metadata
|
|
298
|
-
3. Offer to create visualizations if data is found
|
|
299
|
-
|
|
300
|
-
Always explain what you're doing and interpret results in a helpful way."""
|
|
301
|
-
|
|
302
|
-
def _call_llm(self, messages: list, tools: list) -> dict:
|
|
303
|
-
"""Call LLM via OpenAI-compatible client."""
|
|
304
|
-
response = self.client.chat.completions.create(
|
|
305
|
-
model=self.model_name,
|
|
306
|
-
messages=messages,
|
|
307
|
-
tools=tools,
|
|
308
|
-
tool_choice="auto",
|
|
309
|
-
max_tokens=4096,
|
|
310
|
-
)
|
|
311
|
-
return response.model_dump()
|
|
136
|
+
"""Short, focused system prompt."""
|
|
137
|
+
return f"""You are GeoMind, an AI assistant for satellite imagery analysis using Sentinel-2 data.
|
|
312
138
|
|
|
313
|
-
|
|
314
|
-
"""Execute a function call and return the result."""
|
|
315
|
-
print(f" 🔧 Executing: {name}({args})")
|
|
139
|
+
Current date: {datetime.now().strftime('%Y-%m-%d')}
|
|
316
140
|
|
|
317
|
-
|
|
318
|
-
|
|
141
|
+
KEY RULES:
|
|
142
|
+
1. Use list_recent_imagery to find satellite data for locations
|
|
143
|
+
2. Extract Zarr URLs from results: item['zarr_assets']['SR_10m']['href']
|
|
144
|
+
3. Always include location_name parameter when creating images
|
|
145
|
+
4. Use actual function response data in your answers (don't make up paths/stats)
|
|
146
|
+
5. Let functions use default output paths (saves to outputs/ folder as .png)
|
|
319
147
|
|
|
320
|
-
|
|
321
|
-
result = TOOL_FUNCTIONS[name](**args)
|
|
322
|
-
return result
|
|
323
|
-
except Exception as e:
|
|
324
|
-
return {"error": str(e)}
|
|
148
|
+
Be helpful and accurate - always use real data from function responses."""
|
|
325
149
|
|
|
326
150
|
def chat(self, message: str, verbose: bool = True) -> str:
|
|
327
|
-
"""
|
|
328
|
-
Send a message to the agent and get a response.
|
|
329
|
-
"""
|
|
151
|
+
"""Send message to agent and get response."""
|
|
330
152
|
if verbose:
|
|
331
|
-
print(f"\
|
|
332
|
-
print("
|
|
153
|
+
print(f"\nUser: {message}")
|
|
154
|
+
print("Processing...")
|
|
333
155
|
|
|
334
|
-
# Add user message to history
|
|
335
156
|
self.history.append({"role": "user", "content": message})
|
|
157
|
+
messages = [{"role": "system", "content": self._get_system_prompt()}] + self.history
|
|
158
|
+
|
|
159
|
+
for _ in range(10): # Max 10 iterations
|
|
160
|
+
response = self.client.chat.completions.create(
|
|
161
|
+
model=self.model_name,
|
|
162
|
+
messages=messages,
|
|
163
|
+
tools=TOOLS,
|
|
164
|
+
tool_choice="auto",
|
|
165
|
+
max_tokens=4096,
|
|
166
|
+
)
|
|
336
167
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
max_iterations = 10
|
|
341
|
-
iteration = 0
|
|
342
|
-
|
|
343
|
-
while iteration < max_iterations:
|
|
344
|
-
iteration += 1
|
|
345
|
-
|
|
346
|
-
# Call the model (via proxy or direct)
|
|
347
|
-
response_data = self._call_llm(messages, TOOLS)
|
|
348
|
-
|
|
349
|
-
# Extract assistant message from response
|
|
350
|
-
choice = response_data["choices"][0]
|
|
351
|
-
assistant_message = choice["message"]
|
|
168
|
+
choice = response.choices[0]
|
|
169
|
+
assistant_message = choice.message
|
|
352
170
|
|
|
353
|
-
|
|
354
|
-
tool_calls = assistant_message.get("tool_calls", [])
|
|
171
|
+
tool_calls = assistant_message.tool_calls
|
|
355
172
|
if tool_calls:
|
|
356
|
-
# Add assistant message with tool calls
|
|
357
|
-
messages.append(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
"tool_calls": [
|
|
362
|
-
{
|
|
363
|
-
"id": tc["id"],
|
|
364
|
-
"type": "function",
|
|
365
|
-
"function": {
|
|
366
|
-
"name": tc["function"]["name"],
|
|
367
|
-
"arguments": tc["function"]["arguments"],
|
|
368
|
-
},
|
|
369
|
-
}
|
|
370
|
-
for tc in tool_calls
|
|
371
|
-
],
|
|
372
|
-
}
|
|
373
|
-
)
|
|
374
|
-
|
|
375
|
-
# Execute each tool call
|
|
376
|
-
for tool_call in tool_calls:
|
|
377
|
-
func_name = tool_call["function"]["name"]
|
|
378
|
-
func_args = json.loads(tool_call["function"]["arguments"])
|
|
379
|
-
|
|
380
|
-
result = self._execute_function(func_name, func_args)
|
|
381
|
-
|
|
382
|
-
# Add tool result to messages
|
|
383
|
-
messages.append(
|
|
173
|
+
# Add assistant message with tool calls
|
|
174
|
+
messages.append({
|
|
175
|
+
"role": "assistant",
|
|
176
|
+
"content": assistant_message.content or "",
|
|
177
|
+
"tool_calls": [
|
|
384
178
|
{
|
|
385
|
-
"
|
|
386
|
-
"
|
|
387
|
-
"
|
|
179
|
+
"id": tc.id,
|
|
180
|
+
"type": "function",
|
|
181
|
+
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
|
|
388
182
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
final_text = assistant_message.get("content") or ""
|
|
183
|
+
for tc in tool_calls
|
|
184
|
+
],
|
|
185
|
+
})
|
|
393
186
|
|
|
394
|
-
#
|
|
187
|
+
# Execute tool calls
|
|
188
|
+
for tool_call in tool_calls:
|
|
189
|
+
func_name = tool_call.function.name
|
|
190
|
+
func_args = json.loads(tool_call.function.arguments)
|
|
191
|
+
|
|
192
|
+
print(f" Executing: {func_name}({func_args})")
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
result = TOOL_FUNCTIONS[func_name](**func_args)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
result = {"error": str(e)}
|
|
198
|
+
|
|
199
|
+
messages.append({
|
|
200
|
+
"role": "tool",
|
|
201
|
+
"tool_call_id": tool_call.id,
|
|
202
|
+
"content": json.dumps(result, default=str),
|
|
203
|
+
})
|
|
204
|
+
else:
|
|
205
|
+
# Final response
|
|
206
|
+
final_text = assistant_message.content or ""
|
|
395
207
|
self.history.append({"role": "assistant", "content": final_text})
|
|
396
|
-
|
|
208
|
+
|
|
397
209
|
if verbose:
|
|
398
|
-
print(f"\
|
|
399
|
-
|
|
210
|
+
print(f"\nGeoMind: {final_text}")
|
|
211
|
+
|
|
400
212
|
return final_text
|
|
401
213
|
|
|
402
|
-
return "Max iterations reached
|
|
214
|
+
return "Max iterations reached"
|
|
403
215
|
|
|
404
216
|
def reset(self):
|
|
405
|
-
"""Reset
|
|
217
|
+
"""Reset chat history."""
|
|
406
218
|
self.history = []
|
|
407
|
-
print("🔄 Chat session reset")
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
def main(model: Optional[str] = None):
|
|
411
|
-
"""Main entry point for CLI usage."""
|
|
412
|
-
import sys
|
|
413
|
-
|
|
414
|
-
print("=" * 60)
|
|
415
|
-
print("🌍 GeoMind - Geospatial AI Agent")
|
|
416
|
-
print("=" * 60)
|
|
417
|
-
print("Powered by OpenRouter | Sentinel-2 Imagery")
|
|
418
|
-
print("Type 'quit' or 'exit' to end the session")
|
|
419
|
-
print("Type 'reset' to start a new conversation")
|
|
420
|
-
print("=" * 60)
|
|
421
|
-
|
|
422
|
-
try:
|
|
423
|
-
agent = GeoMindAgent(model=model)
|
|
424
|
-
except ValueError as e:
|
|
425
|
-
print(f"\n❌ Error: {e}")
|
|
426
|
-
sys.exit(1)
|
|
427
|
-
except Exception as e:
|
|
428
|
-
print(f"\n❌ Error: {e}")
|
|
429
|
-
print("\nPlease check your API key and internet connection.")
|
|
430
|
-
sys.exit(1)
|
|
431
|
-
|
|
432
|
-
while True:
|
|
433
|
-
try:
|
|
434
|
-
user_input = input("\n💬 You: ").strip()
|
|
435
|
-
|
|
436
|
-
if not user_input:
|
|
437
|
-
continue
|
|
438
|
-
|
|
439
|
-
if user_input.lower() in ["quit", "exit", "q"]:
|
|
440
|
-
print("\n👋 Goodbye!")
|
|
441
|
-
break
|
|
442
|
-
|
|
443
|
-
if user_input.lower() == "reset":
|
|
444
|
-
agent.reset()
|
|
445
|
-
continue
|
|
446
|
-
|
|
447
|
-
agent.chat(user_input)
|
|
448
|
-
|
|
449
|
-
except KeyboardInterrupt:
|
|
450
|
-
print("\n\n👋 Goodbye!")
|
|
451
|
-
break
|
|
452
|
-
except Exception as e:
|
|
453
|
-
print(f"\n❌ Error: {e}")
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if __name__ == "__main__":
|
|
457
|
-
main()
|
geomind/cli.py
CHANGED
|
@@ -7,6 +7,10 @@ import os
|
|
|
7
7
|
import argparse
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Optional
|
|
10
|
+
import subprocess
|
|
11
|
+
import platform
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
10
14
|
|
|
11
15
|
from .agent import GeoMindAgent
|
|
12
16
|
|
|
@@ -36,6 +40,130 @@ def save_api_key(api_key: str) -> bool:
|
|
|
36
40
|
return False
|
|
37
41
|
|
|
38
42
|
|
|
43
|
+
def display_recent_images():
|
|
44
|
+
"""Display recently created images if any exist."""
|
|
45
|
+
outputs_dir = Path("outputs")
|
|
46
|
+
if not outputs_dir.exists():
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
# Get recent image files (created in last few seconds)
|
|
50
|
+
import time
|
|
51
|
+
recent_threshold = time.time() - 30 # 30 seconds ago
|
|
52
|
+
|
|
53
|
+
recent_images = []
|
|
54
|
+
for ext in ['*.png', '*.jpg', '*.jpeg', '*.tiff']:
|
|
55
|
+
for img_file in outputs_dir.glob(ext):
|
|
56
|
+
if img_file.stat().st_mtime > recent_threshold:
|
|
57
|
+
recent_images.append(img_file)
|
|
58
|
+
|
|
59
|
+
if recent_images:
|
|
60
|
+
print("\n" + "="*60)
|
|
61
|
+
print("Generated Images:")
|
|
62
|
+
for img in recent_images:
|
|
63
|
+
print(f" • {img.name} ({img.stat().st_size // 1024}KB)")
|
|
64
|
+
|
|
65
|
+
# Try to open the most recent image
|
|
66
|
+
if recent_images:
|
|
67
|
+
latest_image = max(recent_images, key=lambda x: x.stat().st_mtime)
|
|
68
|
+
open_image_viewer(latest_image)
|
|
69
|
+
print("="*60)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def open_image_viewer(image_path: Path):
|
|
73
|
+
"""Open image in default viewer."""
|
|
74
|
+
try:
|
|
75
|
+
system = platform.system()
|
|
76
|
+
if system == "Windows":
|
|
77
|
+
os.startfile(str(image_path))
|
|
78
|
+
elif system == "Darwin": # macOS
|
|
79
|
+
subprocess.run(["open", str(image_path)], check=False)
|
|
80
|
+
else: # Linux
|
|
81
|
+
subprocess.run(["xdg-open", str(image_path)], check=False)
|
|
82
|
+
print(f" -> Opened {image_path.name} in default viewer")
|
|
83
|
+
except Exception:
|
|
84
|
+
print(f" -> Saved to: {image_path}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_response_box(title: str, content: str, color_code: str = "\033[94m") -> str:
|
|
88
|
+
"""Format response in an attractive box."""
|
|
89
|
+
RESET = "\033[0m"
|
|
90
|
+
lines = content.split('\n')
|
|
91
|
+
max_width = max(len(line) for line in lines) if lines else 0
|
|
92
|
+
max_width = max(max_width, len(title) + 4)
|
|
93
|
+
width = min(max_width + 4, 80)
|
|
94
|
+
|
|
95
|
+
box = f"{color_code}"
|
|
96
|
+
box += "┌" + "─" * (width - 2) + "┐\n"
|
|
97
|
+
box += f"│ {title:<{width-4}} │\n"
|
|
98
|
+
box += "├" + "─" * (width - 2) + "┤\n"
|
|
99
|
+
|
|
100
|
+
for line in lines:
|
|
101
|
+
if line.strip():
|
|
102
|
+
box += f"│ {line:<{width-4}} │\n"
|
|
103
|
+
else:
|
|
104
|
+
box += f"│{' ' * (width-2)}│\n"
|
|
105
|
+
|
|
106
|
+
box += "└" + "─" * (width - 2) + "┘"
|
|
107
|
+
box += RESET
|
|
108
|
+
return box
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ThinkingIndicator:
|
|
112
|
+
"""Claude Code style thinking animation."""
|
|
113
|
+
|
|
114
|
+
def __init__(self):
|
|
115
|
+
self.is_thinking = False
|
|
116
|
+
self.thread = None
|
|
117
|
+
self.frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
118
|
+
self.thinking_messages = [
|
|
119
|
+
"Thinking",
|
|
120
|
+
"Analyzing satellite data",
|
|
121
|
+
"Processing request",
|
|
122
|
+
"Searching imagery"
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
def start(self):
|
|
126
|
+
"""Start the thinking animation."""
|
|
127
|
+
self.is_thinking = True
|
|
128
|
+
self.thread = threading.Thread(target=self._animate)
|
|
129
|
+
self.thread.daemon = True
|
|
130
|
+
self.thread.start()
|
|
131
|
+
|
|
132
|
+
def stop(self):
|
|
133
|
+
"""Stop the thinking animation."""
|
|
134
|
+
self.is_thinking = False
|
|
135
|
+
if self.thread:
|
|
136
|
+
self.thread.join(timeout=0.1)
|
|
137
|
+
# Clear the line
|
|
138
|
+
print("\r" + " " * 60 + "\r", end="", flush=True)
|
|
139
|
+
|
|
140
|
+
def _animate(self):
|
|
141
|
+
"""Run the thinking animation."""
|
|
142
|
+
frame_idx = 0
|
|
143
|
+
message_idx = 0
|
|
144
|
+
message_counter = 0
|
|
145
|
+
|
|
146
|
+
# Colors like Claude Code
|
|
147
|
+
DIM = '\033[2m'
|
|
148
|
+
RESET = '\033[0m'
|
|
149
|
+
|
|
150
|
+
while self.is_thinking:
|
|
151
|
+
spinner = self.frames[frame_idx % len(self.frames)]
|
|
152
|
+
|
|
153
|
+
# Cycle through thinking messages every 30 frames (3 seconds)
|
|
154
|
+
if message_counter % 30 == 0:
|
|
155
|
+
message_idx = (message_idx + 1) % len(self.thinking_messages)
|
|
156
|
+
|
|
157
|
+
message = self.thinking_messages[message_idx]
|
|
158
|
+
|
|
159
|
+
# Show thinking with shimmer effect like Claude Code
|
|
160
|
+
print(f"\r{DIM}{spinner} {message}...{RESET}", end="", flush=True)
|
|
161
|
+
|
|
162
|
+
time.sleep(0.1)
|
|
163
|
+
frame_idx += 1
|
|
164
|
+
message_counter += 1
|
|
165
|
+
|
|
166
|
+
|
|
39
167
|
def main():
|
|
40
168
|
"""Main CLI entry point for the geomind package."""
|
|
41
169
|
parser = argparse.ArgumentParser(
|
|
@@ -93,9 +221,9 @@ Environment Variables:
|
|
|
93
221
|
if args.clear_key:
|
|
94
222
|
if CONFIG_FILE.exists():
|
|
95
223
|
CONFIG_FILE.unlink()
|
|
96
|
-
print("
|
|
224
|
+
print("Saved API key cleared.")
|
|
97
225
|
else:
|
|
98
|
-
print("
|
|
226
|
+
print("No saved API key found.")
|
|
99
227
|
sys.exit(0)
|
|
100
228
|
|
|
101
229
|
if args.version:
|
|
@@ -112,7 +240,7 @@ Environment Variables:
|
|
|
112
240
|
|
|
113
241
|
api_key = args.api_key or OPENROUTER_API_KEY or get_saved_api_key()
|
|
114
242
|
if not api_key:
|
|
115
|
-
print("
|
|
243
|
+
print("Error: No API key found. Run 'geomind' first to set up.")
|
|
116
244
|
sys.exit(1)
|
|
117
245
|
agent = GeoMindAgent(model=args.model, api_key=api_key)
|
|
118
246
|
agent.chat(args.query)
|
|
@@ -120,27 +248,64 @@ Environment Variables:
|
|
|
120
248
|
# Interactive mode
|
|
121
249
|
run_interactive(model=args.model, api_key=args.api_key)
|
|
122
250
|
except ValueError as e:
|
|
123
|
-
print(f"\
|
|
251
|
+
print(f"\nError: {e}")
|
|
124
252
|
sys.exit(1)
|
|
125
253
|
except KeyboardInterrupt:
|
|
126
|
-
print("\n\
|
|
254
|
+
print("\n\nGoodbye!")
|
|
127
255
|
sys.exit(0)
|
|
128
256
|
except Exception as e:
|
|
129
|
-
print(f"\
|
|
257
|
+
print(f"\nUnexpected error: {e}")
|
|
130
258
|
sys.exit(1)
|
|
131
259
|
|
|
132
260
|
|
|
261
|
+
def print_banner():
|
|
262
|
+
from . import __version__
|
|
263
|
+
|
|
264
|
+
# ANSI color codes
|
|
265
|
+
BOLD = '\033[1m'
|
|
266
|
+
DIM = '\033[2m'
|
|
267
|
+
RESET = '\033[0m'
|
|
268
|
+
|
|
269
|
+
banner = f"""
|
|
270
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
271
|
+
│ {BOLD}>_ GeoMind{RESET} (v{__version__}) │
|
|
272
|
+
│ │
|
|
273
|
+
│ model: nvidia/nemotron-3-nano-30b-a3b:free │
|
|
274
|
+
│ docs: https://harshshinde0.github.io/GeoMind │
|
|
275
|
+
│ authors: Harsh Shinde, Rajat Shinde │
|
|
276
|
+
│ official: https://harshshinde0.github.io/GeoMind │
|
|
277
|
+
│ │
|
|
278
|
+
│ Type "?" for help, "quit" to exit. │
|
|
279
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
280
|
+
"""
|
|
281
|
+
print(banner)
|
|
282
|
+
print()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def print_help():
|
|
286
|
+
"""Print interactive session help."""
|
|
287
|
+
help_text = """
|
|
288
|
+
Interactive Commands:
|
|
289
|
+
help, ? Show this help
|
|
290
|
+
reset Reset conversation
|
|
291
|
+
exit, quit, q Exit GeoMind
|
|
292
|
+
|
|
293
|
+
Query Examples:
|
|
294
|
+
> Find recent Sentinel-2 imagery of Paris
|
|
295
|
+
> Show me NDVI data for the Amazon rainforest
|
|
296
|
+
> Search for images with less than 10% cloud cover in London
|
|
297
|
+
> Get satellite data for coordinates 40.7128, -74.0060
|
|
298
|
+
|
|
299
|
+
For CLI options, run: geomind --help
|
|
300
|
+
"""
|
|
301
|
+
print(help_text)
|
|
302
|
+
|
|
303
|
+
|
|
133
304
|
def run_interactive(model: Optional[str] = None, api_key: Optional[str] = None):
|
|
134
305
|
"""Run interactive CLI mode."""
|
|
135
306
|
from . import __version__
|
|
136
307
|
|
|
137
|
-
|
|
138
|
-
print("🌍 GeoMind - Geospatial AI Agent")
|
|
139
|
-
print("=" * 60)
|
|
140
|
-
print(f"Version: {__version__} | Authors: Harsh Shinde, Rajat Shinde")
|
|
141
|
-
print("Type 'quit' or 'exit' to end the session")
|
|
142
|
-
print("Type 'reset' to start a new conversation")
|
|
143
|
-
print("Type 'geomind --help' for more options")
|
|
308
|
+
print_banner()
|
|
144
309
|
|
|
145
310
|
# Check for API key in order: argument > env > saved file
|
|
146
311
|
from .config import OPENROUTER_API_KEY
|
|
@@ -155,44 +320,72 @@ def run_interactive(model: Optional[str] = None, api_key: Optional[str] = None):
|
|
|
155
320
|
api_key = get_saved_api_key()
|
|
156
321
|
|
|
157
322
|
if not api_key:
|
|
158
|
-
print("\
|
|
323
|
+
print("\nOpenRouter API key required (FREE)")
|
|
159
324
|
print(" Get yours at: https://openrouter.ai/settings/keys\n")
|
|
160
325
|
api_key = input(" Enter your API key: ").strip()
|
|
161
326
|
|
|
162
327
|
if not api_key:
|
|
163
|
-
print("\
|
|
328
|
+
print("\nNo API key provided. Exiting.")
|
|
164
329
|
return
|
|
165
330
|
|
|
166
331
|
# Save the key for future use
|
|
167
332
|
if save_api_key(api_key):
|
|
168
|
-
print("
|
|
333
|
+
print(" API key saved! You won't need to enter it again.\n")
|
|
169
334
|
else:
|
|
170
|
-
print("
|
|
335
|
+
print(" Warning: Could not save API key. You'll need to enter it next time.\n")
|
|
171
336
|
|
|
172
337
|
agent = GeoMindAgent(model=model, api_key=api_key)
|
|
173
338
|
|
|
339
|
+
# Claude Code style color scheme
|
|
340
|
+
CYAN = '\033[96m'
|
|
341
|
+
DIM = '\033[2m'
|
|
342
|
+
BOLD = '\033[1m'
|
|
343
|
+
RESET = '\033[0m'
|
|
344
|
+
|
|
174
345
|
while True:
|
|
175
346
|
try:
|
|
176
|
-
|
|
347
|
+
# Simple prompt like Claude Code
|
|
348
|
+
user_input = input(f"\n{CYAN}>{RESET} ").strip()
|
|
177
349
|
|
|
178
350
|
if not user_input:
|
|
179
351
|
continue
|
|
180
352
|
|
|
181
353
|
if user_input.lower() in ["quit", "exit", "q"]:
|
|
182
|
-
print("\n
|
|
354
|
+
print(f"\n{DIM}Goodbye!{RESET}")
|
|
183
355
|
break
|
|
184
356
|
|
|
185
357
|
if user_input.lower() == "reset":
|
|
186
358
|
agent.reset()
|
|
359
|
+
print(f"{DIM}Started new conversation{RESET}")
|
|
187
360
|
continue
|
|
188
361
|
|
|
189
|
-
|
|
362
|
+
if user_input.lower() in ["help", "?"]:
|
|
363
|
+
print_help()
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
# Start thinking animation
|
|
367
|
+
thinking = ThinkingIndicator()
|
|
368
|
+
thinking.start()
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
# Get response from agent
|
|
372
|
+
response = agent.chat(user_input, verbose=False)
|
|
373
|
+
|
|
374
|
+
# Stop thinking animation
|
|
375
|
+
thinking.stop()
|
|
376
|
+
|
|
377
|
+
# Display response cleanly like Claude Code
|
|
378
|
+
print(f"\n{response}")
|
|
379
|
+
|
|
380
|
+
except Exception as chat_error:
|
|
381
|
+
thinking.stop()
|
|
382
|
+
raise chat_error
|
|
190
383
|
|
|
191
384
|
except KeyboardInterrupt:
|
|
192
|
-
print("\n\n
|
|
385
|
+
print(f"\n\n{DIM}Goodbye!{RESET}")
|
|
193
386
|
break
|
|
194
387
|
except Exception as e:
|
|
195
|
-
print(f"\n
|
|
388
|
+
print(f"\n{DIM}Error: {e}{RESET}")
|
|
196
389
|
|
|
197
390
|
|
|
198
391
|
if __name__ == "__main__":
|
geomind/config.py
CHANGED
|
@@ -37,9 +37,9 @@ REFLECTANCE_OFFSET = -0.1
|
|
|
37
37
|
RGB_BANDS = {"red": "b04", "green": "b03", "blue": "b02"}
|
|
38
38
|
|
|
39
39
|
# Default search parameters
|
|
40
|
-
DEFAULT_MAX_CLOUD_COVER =
|
|
41
|
-
DEFAULT_BUFFER_KM =
|
|
42
|
-
DEFAULT_MAX_ITEMS =
|
|
40
|
+
DEFAULT_MAX_CLOUD_COVER = 50 # percent (increased for better results)
|
|
41
|
+
DEFAULT_BUFFER_KM = 15 # km buffer around point for bbox (increased coverage)
|
|
42
|
+
DEFAULT_MAX_ITEMS = 20 # increased to find more options
|
|
43
43
|
|
|
44
44
|
# Output directory for saved images
|
|
45
45
|
OUTPUT_DIR = Path("outputs")
|
geomind/tools/processing.py
CHANGED
|
@@ -99,6 +99,7 @@ def create_rgb_composite(
|
|
|
99
99
|
zarr_url: str,
|
|
100
100
|
output_path: Optional[str] = None,
|
|
101
101
|
subset_size: Optional[int] = 1000,
|
|
102
|
+
location_name: Optional[str] = None,
|
|
102
103
|
) -> dict:
|
|
103
104
|
"""
|
|
104
105
|
Create an RGB composite image from Sentinel-2 10m bands.
|
|
@@ -106,9 +107,10 @@ def create_rgb_composite(
|
|
|
106
107
|
Uses B04 (Red), B03 (Green), B02 (Blue) bands.
|
|
107
108
|
|
|
108
109
|
Args:
|
|
109
|
-
zarr_url: URL to the SR_10m Zarr asset
|
|
110
|
+
zarr_url: URL to the SR_10m Zarr asset or individual band asset URL
|
|
110
111
|
output_path: Optional path to save the image
|
|
111
112
|
subset_size: Size to subset the image (for faster processing)
|
|
113
|
+
location_name: Optional location name to include in the title
|
|
112
114
|
|
|
113
115
|
Returns:
|
|
114
116
|
Dictionary with path to saved image and metadata
|
|
@@ -116,30 +118,49 @@ def create_rgb_composite(
|
|
|
116
118
|
try:
|
|
117
119
|
import xarray as xr
|
|
118
120
|
import zarr
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
#
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
121
|
+
import re
|
|
122
|
+
|
|
123
|
+
# Determine if this is a band-specific URL or base SR_10m URL
|
|
124
|
+
# Band-specific URLs end with /b01, /b02, etc. (Sentinel-2 band pattern)
|
|
125
|
+
url_parts = zarr_url.rstrip('/').split('/')
|
|
126
|
+
last_part = url_parts[-1]
|
|
127
|
+
is_band_url = re.match(r'^b(0[1-9]|1[0-2]|8a)(_\d+m)?$', last_part.lower()) is not None
|
|
128
|
+
|
|
129
|
+
if is_band_url:
|
|
130
|
+
# Individual band URL provided - need to construct URLs for each band
|
|
131
|
+
base_url = '/'.join(zarr_url.rstrip('/').split('/')[:-1])
|
|
132
|
+
red_zarr = zarr.open(f"{base_url}/b04", mode="r")
|
|
133
|
+
green_zarr = zarr.open(f"{base_url}/b03", mode="r")
|
|
134
|
+
blue_zarr = zarr.open(f"{base_url}/b02", mode="r")
|
|
135
|
+
else:
|
|
136
|
+
# Base SR_10m URL - bands are subdirectories
|
|
137
|
+
base_url = zarr_url.rstrip('/')
|
|
138
|
+
red_zarr = zarr.open(f"{base_url}/b04", mode="r")
|
|
139
|
+
green_zarr = zarr.open(f"{base_url}/b03", mode="r")
|
|
140
|
+
blue_zarr = zarr.open(f"{base_url}/b02", mode="r")
|
|
141
|
+
|
|
142
|
+
# Determine subset region before loading data
|
|
143
|
+
full_shape = red_zarr.shape
|
|
144
|
+
if subset_size and full_shape[0] > subset_size:
|
|
145
|
+
# Calculate center subset
|
|
146
|
+
h, w = full_shape
|
|
147
|
+
start_h = (h - subset_size) // 2
|
|
148
|
+
start_w = (w - subset_size) // 2
|
|
149
|
+
|
|
150
|
+
# Load only the subset (memory efficient)
|
|
151
|
+
red = np.array(red_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
|
|
152
|
+
green = np.array(green_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
|
|
153
|
+
blue = np.array(blue_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
|
|
154
|
+
else:
|
|
155
|
+
# Load full arrays (be careful with large datasets)
|
|
156
|
+
red = np.array(red_zarr)
|
|
157
|
+
green = np.array(green_zarr)
|
|
158
|
+
blue = np.array(blue_zarr)
|
|
129
159
|
|
|
130
160
|
# Subset if requested (for faster processing)
|
|
131
161
|
if subset_size and red.shape[0] > subset_size:
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
start_h = (h - subset_size) // 2
|
|
135
|
-
start_w = (w - subset_size) // 2
|
|
136
|
-
red = red[start_h : start_h + subset_size, start_w : start_w + subset_size]
|
|
137
|
-
green = green[
|
|
138
|
-
start_h : start_h + subset_size, start_w : start_w + subset_size
|
|
139
|
-
]
|
|
140
|
-
blue = blue[
|
|
141
|
-
start_h : start_h + subset_size, start_w : start_w + subset_size
|
|
142
|
-
]
|
|
162
|
+
# Already subsetted during loading - this section can be removed
|
|
163
|
+
pass
|
|
143
164
|
|
|
144
165
|
# Apply scale and offset
|
|
145
166
|
red = _apply_scale_offset(red)
|
|
@@ -163,7 +184,12 @@ def create_rgb_composite(
|
|
|
163
184
|
# Create figure
|
|
164
185
|
fig, ax = plt.subplots(figsize=(10, 10))
|
|
165
186
|
ax.imshow(rgb)
|
|
166
|
-
|
|
187
|
+
|
|
188
|
+
# Create dynamic title with location
|
|
189
|
+
title = "Sentinel-2 RGB Composite (B4/B3/B2)"
|
|
190
|
+
if location_name:
|
|
191
|
+
title += f" - {location_name}"
|
|
192
|
+
ax.set_title(title)
|
|
167
193
|
ax.axis("off")
|
|
168
194
|
|
|
169
195
|
# Save
|
|
@@ -189,6 +215,7 @@ def calculate_ndvi(
|
|
|
189
215
|
zarr_url: str,
|
|
190
216
|
output_path: Optional[str] = None,
|
|
191
217
|
subset_size: Optional[int] = 1000,
|
|
218
|
+
location_name: Optional[str] = None,
|
|
192
219
|
) -> dict:
|
|
193
220
|
"""
|
|
194
221
|
Calculate NDVI (Normalized Difference Vegetation Index) from Sentinel-2 data.
|
|
@@ -197,9 +224,10 @@ def calculate_ndvi(
|
|
|
197
224
|
Uses B08 (NIR) and B04 (Red) bands.
|
|
198
225
|
|
|
199
226
|
Args:
|
|
200
|
-
zarr_url: URL to the SR_10m Zarr asset
|
|
227
|
+
zarr_url: URL to the SR_10m Zarr asset or individual band asset URL
|
|
201
228
|
output_path: Optional path to save the NDVI image
|
|
202
229
|
subset_size: Size to subset the image
|
|
230
|
+
location_name: Optional location name to include in the title
|
|
203
231
|
|
|
204
232
|
Returns:
|
|
205
233
|
Dictionary with NDVI statistics and output path
|
|
@@ -207,21 +235,40 @@ def calculate_ndvi(
|
|
|
207
235
|
try:
|
|
208
236
|
import zarr
|
|
209
237
|
from matplotlib.colors import LinearSegmentedColormap
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
238
|
+
import re
|
|
239
|
+
|
|
240
|
+
# Determine if this is a band-specific URL or base SR_10m URL
|
|
241
|
+
url_parts = zarr_url.rstrip('/').split('/')
|
|
242
|
+
last_part = url_parts[-1]
|
|
243
|
+
is_band_url = re.match(r'^b(0[1-9]|1[0-2]|8a)(_\d+m)?$', last_part.lower()) is not None
|
|
244
|
+
|
|
245
|
+
if is_band_url:
|
|
246
|
+
# Individual band URL provided
|
|
247
|
+
base_url = '/'.join(zarr_url.rstrip('/').split('/')[:-1])
|
|
248
|
+
nir_zarr = zarr.open(f"{base_url}/b08", mode="r")
|
|
249
|
+
red_zarr = zarr.open(f"{base_url}/b04", mode="r")
|
|
250
|
+
else:
|
|
251
|
+
# Base SR_10m URL
|
|
252
|
+
base_url = zarr_url.rstrip('/')
|
|
253
|
+
nir_zarr = zarr.open(f"{base_url}/b08", mode="r")
|
|
254
|
+
red_zarr = zarr.open(f"{base_url}/b04", mode="r")
|
|
255
|
+
|
|
256
|
+
# Determine subset region and load efficiently
|
|
257
|
+
full_shape = nir_zarr.shape
|
|
258
|
+
if subset_size and full_shape[0] > subset_size:
|
|
259
|
+
h, w = full_shape
|
|
221
260
|
start_h = (h - subset_size) // 2
|
|
222
261
|
start_w = (w - subset_size) // 2
|
|
223
|
-
nir =
|
|
224
|
-
red =
|
|
262
|
+
nir = np.array(nir_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
|
|
263
|
+
red = np.array(red_zarr[start_h:start_h + subset_size, start_w:start_w + subset_size])
|
|
264
|
+
else:
|
|
265
|
+
nir = np.array(nir_zarr)
|
|
266
|
+
red = np.array(red_zarr)
|
|
267
|
+
|
|
268
|
+
# Subset if requested - already handled during loading
|
|
269
|
+
if subset_size and nir.shape[0] > subset_size:
|
|
270
|
+
# Already subsetted during loading
|
|
271
|
+
pass
|
|
225
272
|
|
|
226
273
|
# Apply scale and offset
|
|
227
274
|
nir = _apply_scale_offset(nir)
|
|
@@ -256,7 +303,12 @@ def calculate_ndvi(
|
|
|
256
303
|
# Create figure
|
|
257
304
|
fig, ax = plt.subplots(figsize=(10, 10))
|
|
258
305
|
im = ax.imshow(ndvi, cmap=ndvi_cmap, vmin=-1, vmax=1)
|
|
259
|
-
|
|
306
|
+
|
|
307
|
+
# Create dynamic title with location
|
|
308
|
+
title = "NDVI - Normalized Difference Vegetation Index"
|
|
309
|
+
if location_name:
|
|
310
|
+
title += f" - {location_name}"
|
|
311
|
+
ax.set_title(title)
|
|
260
312
|
ax.axis("off")
|
|
261
313
|
|
|
262
314
|
# Add colorbar
|
geomind/tools/stac_search.py
CHANGED
|
@@ -25,6 +25,33 @@ def _format_item(item) -> dict:
|
|
|
25
25
|
"""Format a STAC item into a simplified dictionary."""
|
|
26
26
|
props = item.properties
|
|
27
27
|
|
|
28
|
+
# Extract ALL assets, with special handling for Zarr assets
|
|
29
|
+
assets = {}
|
|
30
|
+
zarr_assets = {}
|
|
31
|
+
other_assets = {}
|
|
32
|
+
|
|
33
|
+
for key, asset in item.assets.items():
|
|
34
|
+
asset_info = {
|
|
35
|
+
"title": asset.title,
|
|
36
|
+
"href": asset.href,
|
|
37
|
+
"type": asset.media_type,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Add band information for individual band assets
|
|
41
|
+
if "_" in key and key.split("_")[0] in ["B01", "B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B09", "B11", "B12"]:
|
|
42
|
+
band_name = key.split("_")[0].lower()
|
|
43
|
+
asset_info["band"] = band_name
|
|
44
|
+
asset_info["resolution"] = key.split("_")[1] if "_" in key else "unknown"
|
|
45
|
+
|
|
46
|
+
# Categorize assets by type
|
|
47
|
+
if asset.media_type and "zarr" in asset.media_type.lower():
|
|
48
|
+
zarr_assets[key] = asset_info
|
|
49
|
+
else:
|
|
50
|
+
other_assets[key] = asset_info
|
|
51
|
+
|
|
52
|
+
# Include all assets in main assets dict
|
|
53
|
+
assets[key] = asset_info
|
|
54
|
+
|
|
28
55
|
return {
|
|
29
56
|
"id": item.id,
|
|
30
57
|
"datetime": props.get("datetime"),
|
|
@@ -32,16 +59,15 @@ def _format_item(item) -> dict:
|
|
|
32
59
|
"platform": props.get("platform"),
|
|
33
60
|
"bbox": item.bbox,
|
|
34
61
|
"geometry": item.geometry,
|
|
35
|
-
"assets":
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"href": asset.href,
|
|
39
|
-
"type": asset.media_type,
|
|
40
|
-
}
|
|
41
|
-
for key, asset in item.assets.items()
|
|
42
|
-
if key in ["SR_10m", "SR_20m", "SR_60m", "TCI_10m", "product"]
|
|
43
|
-
},
|
|
62
|
+
"assets": assets,
|
|
63
|
+
"zarr_assets": zarr_assets, # Separate zarr assets for easy access
|
|
64
|
+
"other_assets": other_assets, # Non-zarr assets
|
|
44
65
|
"stac_url": f"{STAC_API_URL}/collections/{STAC_COLLECTION}/items/{item.id}",
|
|
66
|
+
"asset_summary": {
|
|
67
|
+
"total_assets": len(assets),
|
|
68
|
+
"zarr_assets": len(zarr_assets),
|
|
69
|
+
"other_assets": len(other_assets),
|
|
70
|
+
}
|
|
45
71
|
}
|
|
46
72
|
|
|
47
73
|
|
|
@@ -129,6 +155,10 @@ def search_imagery(
|
|
|
129
155
|
"datetime": datetime_str,
|
|
130
156
|
"max_cloud_cover": max_cloud_cover,
|
|
131
157
|
},
|
|
158
|
+
"quality_info": {
|
|
159
|
+
"avg_cloud_cover": sum(item.properties.get("eo:cloud_cover", 100) for item in items) / len(items) if items else None,
|
|
160
|
+
"best_cloud_cover": min(item.properties.get("eo:cloud_cover", 100) for item in items) if items else None,
|
|
161
|
+
} if items else {},
|
|
132
162
|
}
|
|
133
163
|
|
|
134
164
|
except Exception as e:
|
|
@@ -176,7 +206,7 @@ def get_item_details(item_id: str) -> dict:
|
|
|
176
206
|
|
|
177
207
|
def list_recent_imagery(
|
|
178
208
|
location_name: Optional[str] = None,
|
|
179
|
-
days: int = 7
|
|
209
|
+
days: int = 14, # Changed from 7 to 14 days default
|
|
180
210
|
max_cloud_cover: Optional[float] = None,
|
|
181
211
|
max_items: Optional[int] = None,
|
|
182
212
|
) -> dict:
|
|
@@ -184,10 +214,11 @@ def list_recent_imagery(
|
|
|
184
214
|
List recent Sentinel-2 imagery, optionally for a specific location.
|
|
185
215
|
|
|
186
216
|
This is a convenience function that combines geocoding and search.
|
|
217
|
+
If no good quality results found, automatically extends search period.
|
|
187
218
|
|
|
188
219
|
Args:
|
|
189
220
|
location_name: Optional place name to search around
|
|
190
|
-
days: Number of days to look back (default:
|
|
221
|
+
days: Number of days to look back (default: 14)
|
|
191
222
|
max_cloud_cover: Maximum cloud cover percentage
|
|
192
223
|
max_items: Maximum items to return
|
|
193
224
|
|
|
@@ -228,6 +259,25 @@ def list_recent_imagery(
|
|
|
228
259
|
max_items=max_items,
|
|
229
260
|
)
|
|
230
261
|
|
|
262
|
+
# If no results found and we used a restrictive cloud cover, try extending time and relaxing cloud cover
|
|
263
|
+
if result.get("success") and result.get("filtered_count", 0) == 0 and days < 30:
|
|
264
|
+
# Try 30 days with more permissive cloud cover
|
|
265
|
+
extended_start = end_date - timedelta(days=30)
|
|
266
|
+
relaxed_cloud = min((max_cloud_cover or DEFAULT_MAX_CLOUD_COVER) + 30, 80)
|
|
267
|
+
|
|
268
|
+
extended_result = search_imagery(
|
|
269
|
+
bbox=bbox,
|
|
270
|
+
start_date=extended_start.strftime("%Y-%m-%d"),
|
|
271
|
+
end_date=end_date.strftime("%Y-%m-%d"),
|
|
272
|
+
max_cloud_cover=relaxed_cloud,
|
|
273
|
+
max_items=max_items,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if extended_result.get("success") and extended_result.get("filtered_count", 0) > 0:
|
|
277
|
+
result = extended_result
|
|
278
|
+
result["extended_search"] = True
|
|
279
|
+
result["note"] = f"Extended search to 30 days with {relaxed_cloud}% cloud cover for better results"
|
|
280
|
+
|
|
231
281
|
if location_info:
|
|
232
282
|
result["location"] = location_info
|
|
233
283
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
geomind/__init__.py,sha256=LBwe1KUfm0hXjes2HDyeaN4LZb8tXlRnXqBUDRx3QoI,165
|
|
2
|
+
geomind/agent.py,sha256=My9OikB0RnZulztSML_sPY3-0-dpVvGyfmbzD42Ppp0,8108
|
|
3
|
+
geomind/cli.py,sha256=ipuMF0ZPseHHNKDD5VUMUG4fJDNmG_RQXNvAvT8Gbww,12085
|
|
4
|
+
geomind/config.py,sha256=pgHir8A5tSLbIYz9zOMMwEXhGyECEA0JzStPjQOQfpM,2116
|
|
5
|
+
geomind/tools/__init__.py,sha256=8iumGwIFHh8Bj1VJNgZtmKnEBqCy6_cRkzYENDUH7x4,720
|
|
6
|
+
geomind/tools/geocoding.py,sha256=hiLpzHpkJP6IgWAUtZMnHL6qpkWcYWVLpGe0yfYxXv8,3007
|
|
7
|
+
geomind/tools/processing.py,sha256=4sUrLGqxGqlVETMgHLxOeY-b1_kQ5bstKOjIHh2PbeU,12817
|
|
8
|
+
geomind/tools/stac_search.py,sha256=a0OrxSe4bDNtD_cNHKBnQZHeCtx9B6S65HWxsCBaFxY,8854
|
|
9
|
+
geomind_ai-1.2.0.dist-info/licenses/LICENSE,sha256=aveu0ERm7I3NnIu8rtpKdvd0eyRpmktXKU0PBABtSN0,1069
|
|
10
|
+
geomind_ai-1.2.0.dist-info/METADATA,sha256=-gHApQirq64EklvWj8j7_B-SQjxcsXDCt_HjFe7dJpc,2405
|
|
11
|
+
geomind_ai-1.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
12
|
+
geomind_ai-1.2.0.dist-info/entry_points.txt,sha256=2nPR3faYKl0-1epccvzMJ2xdi-Q1Vt7aOSvA84oIWnw,45
|
|
13
|
+
geomind_ai-1.2.0.dist-info/top_level.txt,sha256=rjKWNSNRhq4R9xJoZGsG-eAaH7BmTVNvfrrbcaJMIIs,8
|
|
14
|
+
geomind_ai-1.2.0.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
geomind/__init__.py,sha256=MZ0Zr2vGCJ816ilSApbwhA6iEfCwEBk40etbvIGfpqs,165
|
|
2
|
-
geomind/agent.py,sha256=2VDhPK9kvxf80KVoJLXbh8JGKbB8a3EiayojXd7ODOM,15661
|
|
3
|
-
geomind/cli.py,sha256=hUONvWUW2QTVawMxpDtFonIAqbS-SiV-bMFthgqnr2g,5614
|
|
4
|
-
geomind/config.py,sha256=7zPr0OKvK2SqQArUbjMvf5GVLQsmHcbn7e-BsSB1yv0,2030
|
|
5
|
-
geomind/tools/__init__.py,sha256=8iumGwIFHh8Bj1VJNgZtmKnEBqCy6_cRkzYENDUH7x4,720
|
|
6
|
-
geomind/tools/geocoding.py,sha256=hiLpzHpkJP6IgWAUtZMnHL6qpkWcYWVLpGe0yfYxXv8,3007
|
|
7
|
-
geomind/tools/processing.py,sha256=mGu20uvpAVil2w2BEqj-0zYhKhCFuZHr1-3-vq0VzqM,10152
|
|
8
|
-
geomind/tools/stac_search.py,sha256=V6230l4aHjedPWXu-3Cjmfc6diSFh5zsycewUko0W8k,6452
|
|
9
|
-
geomind_ai-1.1.0.dist-info/licenses/LICENSE,sha256=aveu0ERm7I3NnIu8rtpKdvd0eyRpmktXKU0PBABtSN0,1069
|
|
10
|
-
geomind_ai-1.1.0.dist-info/METADATA,sha256=EJROiPf8W_iht6qEaSyOzosyVob_hoodOe98ayFXAUE,2405
|
|
11
|
-
geomind_ai-1.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
-
geomind_ai-1.1.0.dist-info/entry_points.txt,sha256=2nPR3faYKl0-1epccvzMJ2xdi-Q1Vt7aOSvA84oIWnw,45
|
|
13
|
-
geomind_ai-1.1.0.dist-info/top_level.txt,sha256=rjKWNSNRhq4R9xJoZGsG-eAaH7BmTVNvfrrbcaJMIIs,8
|
|
14
|
-
geomind_ai-1.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|