saterys 0.2.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- saterys-0.2.7/LICENSE +21 -0
- saterys-0.2.7/MANIFEST.in +4 -0
- saterys-0.2.7/PKG-INFO +50 -0
- saterys-0.2.7/README.md +28 -0
- saterys-0.2.7/pyproject.toml +58 -0
- saterys-0.2.7/saterys/app.py +225 -0
- saterys-0.2.7/saterys/cli.py +26 -0
- saterys-0.2.7/saterys/nodes/NDVI.py +201 -0
- saterys-0.2.7/saterys/nodes/NDWI.py +195 -0
- saterys-0.2.7/saterys/nodes/PCA.py +370 -0
- saterys-0.2.7/saterys/nodes/hello.py +6 -0
- saterys-0.2.7/saterys/nodes/input.py +39 -0
- saterys-0.2.7/saterys/nodes/script.py +13 -0
- saterys-0.2.7/saterys/nodes/sum.py +7 -0
- saterys-0.2.7/saterys/static/assets/ContrastTheme-8vl10reL.js +1 -0
- saterys-0.2.7/saterys/static/assets/Controls-BuZt2rJO.js +1 -0
- saterys-0.2.7/saterys/static/assets/DrawerController-CunOJ5Gd.js +1 -0
- saterys-0.2.7/saterys/static/assets/DrawerController-ZXfKNTdC.css +1 -0
- saterys-0.2.7/saterys/static/assets/Icon-BZoXfbBu.js +1 -0
- saterys-0.2.7/saterys/static/assets/Minimap-6vF3teR8.js +5 -0
- saterys-0.2.7/saterys/static/assets/ThemeToggle-CmShuKPJ.js +1 -0
- saterys-0.2.7/saterys/static/assets/index-CjC_xNd3.css +1 -0
- saterys-0.2.7/saterys/static/assets/index-CwCTxci7.js +13 -0
- saterys-0.2.7/saterys/static/assets/vite-C4kziZTZ.svg +12 -0
- saterys-0.2.7/saterys/static/index.html +14 -0
- saterys-0.2.7/saterys.egg-info/PKG-INFO +50 -0
- saterys-0.2.7/saterys.egg-info/SOURCES.txt +31 -0
- saterys-0.2.7/saterys.egg-info/dependency_links.txt +1 -0
- saterys-0.2.7/saterys.egg-info/entry_points.txt +2 -0
- saterys-0.2.7/saterys.egg-info/requires.txt +6 -0
- saterys-0.2.7/saterys.egg-info/top_level.txt +1 -0
- saterys-0.2.7/setup.cfg +4 -0
- saterys-0.2.7/setup.py +37 -0
saterys-0.2.7/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Your Name
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
saterys-0.2.7/PKG-INFO
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: saterys
|
3
|
+
Version: 0.2.7
|
4
|
+
Summary: Saterys — Scalable Analysis Toolkit for Earth Remote sYStemS
|
5
|
+
Author-email: Sebastian Sanchez Bernal <bastian@example.com>
|
6
|
+
License: MIT
|
7
|
+
Project-URL: Homepage, https://github.com/yourusername/saterys
|
8
|
+
Project-URL: Repository, https://github.com/yourusername/saterys
|
9
|
+
Project-URL: Issues, https://github.com/yourusername/saterys/issues
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
19
|
+
Requires-Python: >=3.9
|
20
|
+
Description-Content-Type: text/markdown
|
21
|
+
License-File: LICENSE
|
22
|
+
|
23
|
+
# SATERYS
|
24
|
+
|
25
|
+
[](https://pypi.org/project/saterys/)
|
26
|
+
[](https://pypi.org/project/saterys/)
|
27
|
+
[](LICENSE)
|
28
|
+
[](https://github.com/bastian6666/saterys/actions)
|
29
|
+
|
30
|
+
**SATERYS** is a geospatial pipeline builder combining a **Svelte** frontend with a **FastAPI** backend.
|
31
|
+
It provides an interactive node-based canvas for connecting operations, executing Python plugins, and visualizing raster data directly on a Leaflet map.
|
32
|
+
|
33
|
+
---
|
34
|
+
|
35
|
+
## ✨ Features
|
36
|
+
|
37
|
+
- 🎨 **Interactive Node Editor** using [Svelvet](https://svelvet.io/).
|
38
|
+
- ⚡ **FastAPI Backend** for plugin execution and REST endpoints.
|
39
|
+
- 🛰 **Geospatial Preview** with Leaflet, serving raster tiles via [rio-tiler](https://github.com/cogeotiff/rio-tiler).
|
40
|
+
- 🔌 **Extensible Plugin System**: add your own nodes by dropping Python files into a `nodes/` folder.
|
41
|
+
- 🌙 **Dark/Light Theme** toggle.
|
42
|
+
- 📦 Fully **pip-installable** with built frontend assets included.
|
43
|
+
|
44
|
+
---
|
45
|
+
|
46
|
+
## 📦 Installation
|
47
|
+
|
48
|
+
```bash
|
49
|
+
pip install saterys
|
50
|
+
# SATERYS
|
saterys-0.2.7/README.md
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# SATERYS
|
2
|
+
|
3
|
+
[](https://pypi.org/project/saterys/)
|
4
|
+
[](https://pypi.org/project/saterys/)
|
5
|
+
[](LICENSE)
|
6
|
+
[](https://github.com/bastian6666/saterys/actions)
|
7
|
+
|
8
|
+
**SATERYS** is a geospatial pipeline builder combining a **Svelte** frontend with a **FastAPI** backend.
|
9
|
+
It provides an interactive node-based canvas for connecting operations, executing Python plugins, and visualizing raster data directly on a Leaflet map.
|
10
|
+
|
11
|
+
---
|
12
|
+
|
13
|
+
## ✨ Features
|
14
|
+
|
15
|
+
- 🎨 **Interactive Node Editor** using [Svelvet](https://svelvet.io/).
|
16
|
+
- ⚡ **FastAPI Backend** for plugin execution and REST endpoints.
|
17
|
+
- 🛰 **Geospatial Preview** with Leaflet, serving raster tiles via [rio-tiler](https://github.com/cogeotiff/rio-tiler).
|
18
|
+
- 🔌 **Extensible Plugin System**: add your own nodes by dropping Python files into a `nodes/` folder.
|
19
|
+
- 🌙 **Dark/Light Theme** toggle.
|
20
|
+
- 📦 Fully **pip-installable** with built frontend assets included.
|
21
|
+
|
22
|
+
---
|
23
|
+
|
24
|
+
## 📦 Installation
|
25
|
+
|
26
|
+
```bash
|
27
|
+
pip install saterys
|
28
|
+
# SATERYS
|
@@ -0,0 +1,58 @@
|
|
1
|
+
[build-system]
|
2
|
+
requires = ["setuptools>=68", "wheel", "build"]
|
3
|
+
build-backend = "setuptools.build_meta"
|
4
|
+
|
5
|
+
[project]
|
6
|
+
name = "saterys"
|
7
|
+
version = "0.2.7" # bump each release
|
8
|
+
description = "Saterys — Scalable Analysis Toolkit for Earth Remote sYStemS"
|
9
|
+
readme = "README.md"
|
10
|
+
license = { text = "MIT" }
|
11
|
+
|
12
|
+
authors = [
|
13
|
+
{ name = "Sebastian Sanchez Bernal", email = "bastian@example.com" }
|
14
|
+
]
|
15
|
+
|
16
|
+
requires-python = ">=3.9"
|
17
|
+
|
18
|
+
dependencies = [
|
19
|
+
"fastapi>=0.110", # modern FastAPI (with Pydantic v2 support)
|
20
|
+
"uvicorn[standard]>=0.23", # ASGI server
|
21
|
+
"pydantic>=2", # data validation
|
22
|
+
"numpy>=1.26", # wheels available for Python 3.12
|
23
|
+
"rio-tiler>=6", # raster tiling & preview
|
24
|
+
"rasterio>=1.3", # geospatial raster I/O
|
25
|
+
]
|
26
|
+
|
27
|
+
classifiers = [
|
28
|
+
"Development Status :: 3 - Alpha",
|
29
|
+
"Intended Audience :: Science/Research",
|
30
|
+
"Topic :: Scientific/Engineering :: GIS",
|
31
|
+
"License :: OSI Approved :: MIT License",
|
32
|
+
"Programming Language :: Python :: 3",
|
33
|
+
"Programming Language :: Python :: 3.9",
|
34
|
+
"Programming Language :: Python :: 3.10",
|
35
|
+
"Programming Language :: Python :: 3.11",
|
36
|
+
"Programming Language :: Python :: 3.12",
|
37
|
+
]
|
38
|
+
|
39
|
+
[project.urls]
|
40
|
+
Homepage = "https://github.com/yourusername/saterys"
|
41
|
+
Repository = "https://github.com/yourusername/saterys"
|
42
|
+
Issues = "https://github.com/yourusername/saterys/issues"
|
43
|
+
|
44
|
+
[project.scripts]
|
45
|
+
saterys = "saterys.cli:main"
|
46
|
+
|
47
|
+
[tool.setuptools]
|
48
|
+
include-package-data = true
|
49
|
+
|
50
|
+
[tool.setuptools.packages.find]
|
51
|
+
include = ["saterys*"]
|
52
|
+
|
53
|
+
[tool.setuptools.package-data]
|
54
|
+
saterys = [
|
55
|
+
"nodes/*.py",
|
56
|
+
"static/*",
|
57
|
+
"static/**/*",
|
58
|
+
]
|
@@ -0,0 +1,225 @@
|
|
1
|
+
# saterys/app.py (Python 3.7 compatible)
|
2
|
+
|
3
|
+
from fastapi import FastAPI, HTTPException
|
4
|
+
from fastapi.middleware.cors import CORSMiddleware
|
5
|
+
from fastapi.responses import Response, FileResponse
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
7
|
+
from pydantic import BaseModel
|
8
|
+
from typing import Any, Dict # IMPORTANT: Dict[...] for Py3.7
|
9
|
+
import importlib.util
|
10
|
+
import os
|
11
|
+
import sys
|
12
|
+
|
13
|
+
# ------------------------------------------------------------------------------
|
14
|
+
# FastAPI app + CORS
|
15
|
+
# ------------------------------------------------------------------------------
|
16
|
+
app = FastAPI()
|
17
|
+
|
18
|
+
app.add_middleware(
|
19
|
+
CORSMiddleware,
|
20
|
+
allow_origins=["*"], # or list specific origins if you prefer
|
21
|
+
allow_credentials=True,
|
22
|
+
allow_methods=["*"],
|
23
|
+
allow_headers=["*"],
|
24
|
+
)
|
25
|
+
|
26
|
+
|
27
|
+
# ------------------------------------------------------------------------------
|
28
|
+
# Plugin discovery — load BOTH package nodes AND ./nodes
|
29
|
+
# ------------------------------------------------------------------------------
|
30
|
+
PKG_NODE_DIR = os.path.join(os.path.dirname(__file__), "nodes") # built-ins
|
31
|
+
CWD_NODE_DIR = os.path.join(os.getcwd(), "nodes") # user workspace
|
32
|
+
|
33
|
+
PLUGINS: Dict[str, Any] = {} # name -> module
|
34
|
+
|
35
|
+
def _load_dir(d: str):
|
36
|
+
if not os.path.isdir(d):
|
37
|
+
return
|
38
|
+
for fn in os.listdir(d):
|
39
|
+
if not fn.endswith(".py") or fn.startswith("__"):
|
40
|
+
continue
|
41
|
+
path = os.path.join(d, fn)
|
42
|
+
spec = importlib.util.spec_from_file_location(fn[:-3], path)
|
43
|
+
if not spec or not spec.loader:
|
44
|
+
continue
|
45
|
+
mod = importlib.util.module_from_spec(spec)
|
46
|
+
sys.modules[fn[:-3]] = mod
|
47
|
+
spec.loader.exec_module(mod)
|
48
|
+
name = getattr(mod, "NAME", fn[:-3])
|
49
|
+
PLUGINS[name] = mod
|
50
|
+
print(f"Loaded plugin: {name} from {path}")
|
51
|
+
|
52
|
+
def discover_plugins():
|
53
|
+
PLUGINS.clear()
|
54
|
+
_load_dir(PKG_NODE_DIR) # package built-ins
|
55
|
+
_load_dir(CWD_NODE_DIR) # user ./nodes
|
56
|
+
|
57
|
+
discover_plugins()
|
58
|
+
|
59
|
+
# ------------------------------------------------------------------------------
|
60
|
+
# API models + endpoints
|
61
|
+
# ------------------------------------------------------------------------------
|
62
|
+
class RunPayload(BaseModel):
|
63
|
+
nodeId: str
|
64
|
+
type: str # plugin name
|
65
|
+
args: Dict[str, Any] = {}
|
66
|
+
inputs: Dict[str, Any] = {} # optional upstream data
|
67
|
+
|
68
|
+
@app.get("/node_types")
|
69
|
+
def node_types():
|
70
|
+
out = []
|
71
|
+
for name, mod in PLUGINS.items():
|
72
|
+
out.append({
|
73
|
+
"name": name,
|
74
|
+
"default_args": getattr(mod, "DEFAULT_ARGS", {}),
|
75
|
+
})
|
76
|
+
return {"types": out}
|
77
|
+
|
78
|
+
@app.post("/run_node")
|
79
|
+
def run_node(p: RunPayload):
|
80
|
+
mod = PLUGINS.get(p.type)
|
81
|
+
if not mod:
|
82
|
+
return {"ok": False, "error": "unknown node type '%s'" % p.type}
|
83
|
+
try:
|
84
|
+
res = mod.run(p.args or {}, p.inputs or {}, {"nodeId": p.nodeId})
|
85
|
+
return {"ok": True, "output": res}
|
86
|
+
except Exception as e:
|
87
|
+
return {"ok": False, "error": str(e)}
|
88
|
+
|
89
|
+
# ------------------------------------------------------------------------------
|
90
|
+
# Raster preview endpoints (Leaflet tiles) — Py3.7 compatible
|
91
|
+
# Requires: pip install "rio-tiler<6" numpy
|
92
|
+
# ------------------------------------------------------------------------------
|
93
|
+
from typing import Dict as _Dict # avoid confusion with above
|
94
|
+
import numpy as np
|
95
|
+
|
96
|
+
# rio-tiler compatibility: use Reader if available (v6+), else COGReader (v<6)
|
97
|
+
try:
|
98
|
+
from rio_tiler.io import Reader as _RTReader # rio-tiler >= 6 (needs Python >= 3.8)
|
99
|
+
except Exception:
|
100
|
+
try:
|
101
|
+
from rio_tiler.io import COGReader as _RTReader # rio-tiler < 6 (Py3.7 OK)
|
102
|
+
except Exception as _e:
|
103
|
+
raise ImportError(
|
104
|
+
"rio-tiler not available. Install a Py3.7-compatible version:\n"
|
105
|
+
" pip install 'rio-tiler<6' numpy"
|
106
|
+
) from _e
|
107
|
+
|
108
|
+
# Simple registry: preview id -> absolute path
|
109
|
+
PREVIEWS: _Dict[str, str] = {}
|
110
|
+
|
111
|
+
@app.post("/preview/register")
|
112
|
+
def preview_register(payload: Dict[str, str]):
|
113
|
+
"""
|
114
|
+
Body: { "id": "myRaster1", "path": "/abs/path/to/file.tif" }
|
115
|
+
"""
|
116
|
+
rid = str(payload.get("id", "")).strip()
|
117
|
+
pth = str(payload.get("path", "")).strip()
|
118
|
+
if not rid or not pth:
|
119
|
+
raise HTTPException(400, "id and path are required")
|
120
|
+
ap = os.path.abspath(pth)
|
121
|
+
if not os.path.exists(ap):
|
122
|
+
raise HTTPException(404, "file not found: %s" % ap)
|
123
|
+
PREVIEWS[rid] = ap
|
124
|
+
return {"ok": True, "id": rid, "path": ap}
|
125
|
+
|
126
|
+
@app.get("/preview/bounds/{rid}")
|
127
|
+
def preview_bounds(rid: str):
|
128
|
+
path = PREVIEWS.get(rid)
|
129
|
+
if not path:
|
130
|
+
raise HTTPException(404, "unknown preview id")
|
131
|
+
with _RTReader(path) as r:
|
132
|
+
west, south, east, north = r.geographic_bounds # lon/lat
|
133
|
+
return {"bounds": [west, south, east, north], "crs": "EPSG:4326"}
|
134
|
+
|
135
|
+
@app.get("/preview/tile/{rid}/{z}/{x}/{y}.png")
|
136
|
+
def preview_tile(rid: str, z: int, x: int, y: int, indexes: str = ""):
|
137
|
+
"""
|
138
|
+
Return a PNG tile for the registered raster.
|
139
|
+
- ?indexes=4,3,2 chooses 1-based band indexes. If omitted: RGB if >=3 bands else band 1 grayscale.
|
140
|
+
- Per-tile p2/p98 stretch to 0..255 for a decent look without dataset stats.
|
141
|
+
"""
|
142
|
+
path = PREVIEWS.get(rid)
|
143
|
+
if not path:
|
144
|
+
raise HTTPException(404, "unknown preview id")
|
145
|
+
|
146
|
+
# parse ?indexes
|
147
|
+
idx = None
|
148
|
+
if indexes:
|
149
|
+
try:
|
150
|
+
idx = tuple(int(i) for i in indexes.split(",") if i.strip())
|
151
|
+
except Exception:
|
152
|
+
raise HTTPException(400, "bad indexes param; expected comma-separated integers")
|
153
|
+
|
154
|
+
with _RTReader(path) as r:
|
155
|
+
# detect band count (compat across versions)
|
156
|
+
band_count = getattr(getattr(r, "dataset", None), "count", None)
|
157
|
+
if band_count is None:
|
158
|
+
try:
|
159
|
+
info = r.info()
|
160
|
+
band_count = len(info.get("band_metadata", []))
|
161
|
+
except Exception:
|
162
|
+
band_count = 1
|
163
|
+
if idx is None:
|
164
|
+
idx = (1, 2, 3) if (band_count and band_count >= 3) else (1,)
|
165
|
+
|
166
|
+
data, mask = r.tile(x, y, z, indexes=idx) # data: (bands, H, W), mask: HxW
|
167
|
+
|
168
|
+
# Per-band percentile stretch
|
169
|
+
out_bands = []
|
170
|
+
for b in range(data.shape[0]):
|
171
|
+
arr = data[b].astype("float32")
|
172
|
+
if mask is not None:
|
173
|
+
valid = (mask != 0) # treat 0 as nodata
|
174
|
+
arr = np.where(valid, arr, np.nan)
|
175
|
+
finite = np.isfinite(arr)
|
176
|
+
if not np.any(finite):
|
177
|
+
scaled = np.zeros_like(arr, dtype="uint8")
|
178
|
+
else:
|
179
|
+
vals = arr[finite]
|
180
|
+
p2, p98 = np.percentile(vals, (2, 98))
|
181
|
+
if not np.isfinite(p2) or not np.isfinite(p98) or (p98 <= p2):
|
182
|
+
scaled = np.zeros_like(arr, dtype="uint8")
|
183
|
+
else:
|
184
|
+
scaledf = (arr - p2) / (p98 - p2) * 255.0
|
185
|
+
scaled = np.clip(scaledf, 0, 255)
|
186
|
+
scaled = np.where(finite, scaled, 0).astype("uint8")
|
187
|
+
out_bands.append(scaled)
|
188
|
+
|
189
|
+
data8 = np.stack(out_bands, axis=0)
|
190
|
+
|
191
|
+
# Compose RGB
|
192
|
+
if data8.shape[0] == 1:
|
193
|
+
rgb = np.vstack([data8, data8, data8])
|
194
|
+
elif data8.shape[0] >= 3:
|
195
|
+
rgb = data8[:3]
|
196
|
+
else: # 2 bands -> duplicate last
|
197
|
+
rgb = np.vstack([data8, data8[-1:]])
|
198
|
+
|
199
|
+
# Encode PNG
|
200
|
+
from rio_tiler.utils import render
|
201
|
+
img = render(rgb, mask=mask, img_format="PNG")
|
202
|
+
return Response(content=img, media_type="image/png")
|
203
|
+
|
204
|
+
# ------------------------------------------------------------------------------
|
205
|
+
# Serve built frontend (compiled Svelte) from saterys/static at "/"
|
206
|
+
# ------------------------------------------------------------------------------
|
207
|
+
|
208
|
+
_here = os.path.dirname(__file__)
|
209
|
+
static_dir = os.path.join(_here, "static")
|
210
|
+
|
211
|
+
if os.path.isdir(static_dir):
|
212
|
+
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
213
|
+
|
214
|
+
# 👇 extra mount so /assets/* works when index.html uses absolute /assets URLs
|
215
|
+
assets_dir = os.path.join(static_dir, "assets")
|
216
|
+
if os.path.isdir(assets_dir):
|
217
|
+
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
218
|
+
|
219
|
+
@app.get("/")
|
220
|
+
def root():
|
221
|
+
index_path = os.path.join(static_dir, "index.html")
|
222
|
+
if not os.path.exists(index_path):
|
223
|
+
raise HTTPException(404, "Frontend not built. Run: (cd saterys/web && npm install && npm run build)")
|
224
|
+
return FileResponse(index_path)
|
225
|
+
|
@@ -0,0 +1,26 @@
|
|
1
|
+
import argparse, os, subprocess, sys, time, webbrowser
|
2
|
+
from pathlib import Path
|
3
|
+
import uvicorn
|
4
|
+
|
5
|
+
def main():
|
6
|
+
ap = argparse.ArgumentParser(prog="saterys", description="Saterys runner")
|
7
|
+
ap.add_argument("--host", default=os.environ.get("SATERYS_HOST", "127.0.0.1"))
|
8
|
+
ap.add_argument("--port", type=int, default=int(os.environ.get("SATERYS_PORT", "8000")))
|
9
|
+
ap.add_argument("--dev", action="store_true", help="Run Vite (frontend) + Uvicorn (backend)")
|
10
|
+
args = ap.parse_args()
|
11
|
+
|
12
|
+
if args.dev:
|
13
|
+
# Frontend path is inside package: saterys/web
|
14
|
+
web_dir = Path(__file__).resolve().parent / "web"
|
15
|
+
if (web_dir / "package.json").exists():
|
16
|
+
os.environ.setdefault("SATERYS_DEV_ORIGIN", "http://localhost:5173")
|
17
|
+
print("Starting Vite (frontend) on :5173")
|
18
|
+
vite = subprocess.Popen(["npm", "run", "dev", "--", "--port=5173"], cwd=str(web_dir))
|
19
|
+
time.sleep(1.0)
|
20
|
+
try: webbrowser.open("http://localhost:5173")
|
21
|
+
except Exception: pass
|
22
|
+
else:
|
23
|
+
print("No frontend found at saterys/web. Serving built assets only.")
|
24
|
+
|
25
|
+
print(f"Starting Saterys API → http://{args.host}:{args.port}")
|
26
|
+
uvicorn.run("saterys.app:app", host=args.host, port=args.port, reload=bool(args.dev))
|
@@ -0,0 +1,201 @@
|
|
1
|
+
# nodes/ndvi.py
|
2
|
+
"""
|
3
|
+
NDVI node.
|
4
|
+
- If there are TWO upstream raster inputs: uses them as RED and NIR (you can hint which one via args.prefer_upstream_*).
|
5
|
+
- If there is ONE upstream raster input: reads specified band indices for red/nir.
|
6
|
+
- Saves NDVI as a GeoTIFF and returns a raster payload (path + metadata).
|
7
|
+
|
8
|
+
Requirements: rasterio, numpy
|
9
|
+
"""
|
10
|
+
|
11
|
+
from __future__ import annotations
|
12
|
+
import os, hashlib
|
13
|
+
from typing import Any, Dict, Tuple
|
14
|
+
|
15
|
+
NAME = "raster.ndvi"
|
16
|
+
DEFAULT_ARGS = {
|
17
|
+
# When only one upstream raster is connected, read these band indices (1-based!)
|
18
|
+
"red_band": 4, # e.g. Landsat 8: B4 is red
|
19
|
+
"nir_band": 5, # Landsat 8: B5 is NIR
|
20
|
+
# When TWO upstream rasters are connected, you can optionally hint which node id is which:
|
21
|
+
"prefer_upstream_red_id": "",
|
22
|
+
"prefer_upstream_nir_id": "",
|
23
|
+
# Output
|
24
|
+
"output_path": "", # if empty, auto-cache to ./data/cache/ndvi-<hash>.tif
|
25
|
+
"dtype": "float32",
|
26
|
+
"nodata": -9999.0
|
27
|
+
}
|
28
|
+
|
29
|
+
def _cache_dir() -> str:
|
30
|
+
return os.path.abspath(os.getenv("RASTER_CACHE", "./data/cache"))
|
31
|
+
|
32
|
+
def _ensure_dir(p: str):
|
33
|
+
os.makedirs(p, exist_ok=True)
|
34
|
+
|
35
|
+
def _auto_name(paths: Tuple[str, ...], bands: Tuple[int, int]) -> str:
|
36
|
+
h = hashlib.sha1(("|".join(paths) + f"|{bands}").encode("utf-8")).hexdigest()[:16]
|
37
|
+
return f"ndvi-{h}.tif"
|
38
|
+
|
39
|
+
def _first_two_rasters(inputs: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
40
|
+
"""Return a dict of {up_id: raster_payload} for the first two raster inputs found."""
|
41
|
+
out = {}
|
42
|
+
for up_id, v in inputs.items():
|
43
|
+
if isinstance(v, dict) and v.get("type") == "raster" and v.get("path"):
|
44
|
+
out[up_id] = v
|
45
|
+
if len(out) == 2:
|
46
|
+
break
|
47
|
+
return out
|
48
|
+
|
49
|
+
def _assert_same_grid(r1, r2):
|
50
|
+
if r1["crs"] != r2["crs"]:
|
51
|
+
raise ValueError(f"NDVI: CRS mismatch: {r1['crs']} vs {r2['crs']}")
|
52
|
+
if r1["transform"] != r2["transform"]:
|
53
|
+
raise ValueError("NDVI: transform (georeferencing) mismatch between inputs")
|
54
|
+
if r1["width"] != r2["width"] or r1["height"] != r2["height"]:
|
55
|
+
raise ValueError("NDVI: raster shapes differ; please resample beforehand")
|
56
|
+
|
57
|
+
def run(args: Dict[str, Any], inputs: Dict[str, Any], context: Dict[str, Any]):
|
58
|
+
import numpy as np
|
59
|
+
import rasterio
|
60
|
+
from rasterio.enums import Resampling
|
61
|
+
|
62
|
+
# 1) Gather upstream rasters
|
63
|
+
rasters = _first_two_rasters(inputs)
|
64
|
+
|
65
|
+
# 2) Resolve operating mode
|
66
|
+
prefer_red = (args.get("prefer_upstream_red_id") or "").strip() or None
|
67
|
+
prefer_nir = (args.get("prefer_upstream_nir_id") or "").strip() or None
|
68
|
+
|
69
|
+
red_arr = None
|
70
|
+
nir_arr = None
|
71
|
+
meta_source = None # template for writing output
|
72
|
+
|
73
|
+
if len(rasters) >= 2:
|
74
|
+
# --- Two-raster mode ---
|
75
|
+
# Determine which is red and which is nir
|
76
|
+
red_payload = None
|
77
|
+
nir_payload = None
|
78
|
+
|
79
|
+
# If preferences provided and present, honor them
|
80
|
+
if prefer_red and prefer_red in rasters:
|
81
|
+
red_payload = rasters[prefer_red]
|
82
|
+
if prefer_nir and prefer_nir in rasters:
|
83
|
+
nir_payload = rasters[prefer_nir]
|
84
|
+
|
85
|
+
# If not both resolved, assign remaining by order
|
86
|
+
remaining = [v for k, v in rasters.items() if v not in (red_payload, nir_payload)]
|
87
|
+
if red_payload is None and remaining:
|
88
|
+
red_payload = remaining.pop(0)
|
89
|
+
if nir_payload is None and remaining:
|
90
|
+
nir_payload = remaining.pop(0)
|
91
|
+
|
92
|
+
if red_payload is None or nir_payload is None:
|
93
|
+
raise ValueError("NDVI: need two upstream rasters or band indices")
|
94
|
+
|
95
|
+
_assert_same_grid(red_payload, nir_payload)
|
96
|
+
|
97
|
+
# Read first band from each file by default
|
98
|
+
with rasterio.open(red_payload["path"]) as ds_red, rasterio.open(nir_payload["path"]) as ds_nir:
|
99
|
+
red = ds_red.read(1, masked=True).astype("float32")
|
100
|
+
nir = ds_nir.read(1, masked=True).astype("float32")
|
101
|
+
meta_source = ds_red # use RED for profile
|
102
|
+
|
103
|
+
# Prefer nodata masks from both
|
104
|
+
mask = red.mask | nir.mask
|
105
|
+
red_arr = np.ma.array(red, mask=mask)
|
106
|
+
nir_arr = np.ma.array(nir, mask=mask)
|
107
|
+
|
108
|
+
out_name_paths = (red_payload["path"], nir_payload["path"])
|
109
|
+
bands_used = (1, 1)
|
110
|
+
|
111
|
+
else:
|
112
|
+
# --- Single multiband mode ---
|
113
|
+
# Find a single upstream raster
|
114
|
+
raster = next((v for v in inputs.values() if isinstance(v, dict) and v.get("type") == "raster"), None)
|
115
|
+
if raster is None:
|
116
|
+
raise ValueError("NDVI: no upstream raster found")
|
117
|
+
|
118
|
+
red_band = int(args.get("red_band", 4))
|
119
|
+
nir_band = int(args.get("nir_band", 5))
|
120
|
+
if red_band < 1 or nir_band < 1:
|
121
|
+
raise ValueError("NDVI: band indices are 1-based and must be >= 1")
|
122
|
+
|
123
|
+
with rasterio.open(raster["path"]) as ds:
|
124
|
+
if red_band > ds.count or nir_band > ds.count:
|
125
|
+
raise ValueError(f"NDVI: band index out of range (count={ds.count})")
|
126
|
+
red = ds.read(red_band, masked=True).astype("float32")
|
127
|
+
nir = ds.read(nir_band, masked=True).astype("float32")
|
128
|
+
meta_source = ds
|
129
|
+
mask = red.mask | nir.mask
|
130
|
+
red_arr = np.ma.array(red, mask=mask)
|
131
|
+
nir_arr = np.ma.array(nir, mask=mask)
|
132
|
+
|
133
|
+
out_name_paths = (raster["path"],)
|
134
|
+
bands_used = (red_band, nir_band)
|
135
|
+
|
136
|
+
# 3) Compute NDVI = (NIR - RED) / (NIR + RED)
|
137
|
+
# Handle divide-by-zero and propagate masks.
|
138
|
+
num = (nir_arr - red_arr)
|
139
|
+
den = (nir_arr + red_arr)
|
140
|
+
# Avoid runtime warnings; where den == 0, set masked
|
141
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
142
|
+
ndvi = np.ma.divide(num, den)
|
143
|
+
ndvi.mask = np.ma.getmaskarray(ndvi) | (den == 0)
|
144
|
+
|
145
|
+
out_dtype = str(args.get("dtype", "float32")).lower()
|
146
|
+
if out_dtype not in ("float32", "float64"):
|
147
|
+
out_dtype = "float32"
|
148
|
+
nodata_val = float(args.get("nodata", -9999.0))
|
149
|
+
|
150
|
+
# 4) Prepare output path
|
151
|
+
out_path = (args.get("output_path") or "").strip()
|
152
|
+
if not out_path:
|
153
|
+
cache_root = _cache_dir()
|
154
|
+
_ensure_dir(cache_root)
|
155
|
+
out_path = os.path.join(cache_root, _auto_name(out_name_paths, bands_used))
|
156
|
+
else:
|
157
|
+
_ensure_dir(os.path.dirname(os.path.abspath(out_path)))
|
158
|
+
|
159
|
+
# 5) Write GeoTIFF
|
160
|
+
with rasterio.open(out_path, "w",
|
161
|
+
driver="GTiff",
|
162
|
+
height=ndvi.shape[0],
|
163
|
+
width=ndvi.shape[1],
|
164
|
+
count=1,
|
165
|
+
dtype=out_dtype,
|
166
|
+
crs=meta_source.crs,
|
167
|
+
transform=meta_source.transform,
|
168
|
+
nodata=nodata_val) as dst:
|
169
|
+
# Fill masked pixels with nodata
|
170
|
+
out = ndvi.filled(nodata_val).astype(out_dtype)
|
171
|
+
dst.write(out, 1)
|
172
|
+
# Optional description
|
173
|
+
dst.set_band_description(1, "NDVI")
|
174
|
+
|
175
|
+
# 6) Return a raster payload for downstream nodes
|
176
|
+
from rasterio.coords import BoundingBox
|
177
|
+
with rasterio.open(out_path) as ds:
|
178
|
+
bb = ds.bounds
|
179
|
+
payload = {
|
180
|
+
"type": "raster",
|
181
|
+
"driver": ds.driver,
|
182
|
+
"path": os.path.abspath(out_path),
|
183
|
+
"width": ds.width,
|
184
|
+
"height": ds.height,
|
185
|
+
"count": ds.count,
|
186
|
+
"dtype": ds.dtypes[0],
|
187
|
+
"crs": str(ds.crs) if ds.crs else None,
|
188
|
+
"transform": [ds.transform.a, ds.transform.b, ds.transform.c,
|
189
|
+
ds.transform.d, ds.transform.e, ds.transform.f],
|
190
|
+
"bounds": [bb.left, bb.bottom, bb.right, bb.top],
|
191
|
+
"nodata": ds.nodata,
|
192
|
+
"band_names": ["NDVI"],
|
193
|
+
"meta": {
|
194
|
+
"source": "ndvi",
|
195
|
+
"mode": "two_rasters" if len(rasters) >= 2 else "multiband",
|
196
|
+
"inputs": list(out_name_paths),
|
197
|
+
"bands_used": {"red": bands_used[0], "nir": bands_used[1]},
|
198
|
+
},
|
199
|
+
}
|
200
|
+
|
201
|
+
return payload
|