satchange 0.1.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.
- satchange/__init__.py +8 -0
- satchange/detect.py +311 -0
- satchange/gee_utils.py +117 -0
- satchange/indices.py +201 -0
- satchange/make_map.py +71 -0
- satchange/mapmaker.py +275 -0
- satchange/mpc_backend.py +476 -0
- satchange/scenarios.py +274 -0
- satchange/sites.py +70 -0
- satchange-0.1.0.dist-info/METADATA +125 -0
- satchange-0.1.0.dist-info/RECORD +14 -0
- satchange-0.1.0.dist-info/WHEEL +4 -0
- satchange-0.1.0.dist-info/entry_points.txt +3 -0
- satchange-0.1.0.dist-info/licenses/LICENSE +21 -0
satchange/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""satchange — multipurpose satellite change detection.
|
|
2
|
+
|
|
3
|
+
Map deforestation, mining, urbanisation, floods, burns, surface-water change and
|
|
4
|
+
multi-epoch urban growth from free Sentinel-1/2 and Landsat data, via Google
|
|
5
|
+
Earth Engine or Microsoft Planetary Computer (no account needed). Pure Python.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
satchange/detect.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Multipurpose satellite change detection.
|
|
3
|
+
|
|
4
|
+
Pick a SCENARIO and a LOCATION; the scenario selects the remote-sensing method
|
|
5
|
+
(NDVI/NDBI/NDWI/NBR change, SIRAD radar, or SAR flood water). Results download
|
|
6
|
+
straight to disk as a PNG quick-look, a georeferenced GeoTIFF, and a stats JSON.
|
|
7
|
+
|
|
8
|
+
Examples
|
|
9
|
+
--------
|
|
10
|
+
# List available scenarios
|
|
11
|
+
python3 detect.py --list
|
|
12
|
+
|
|
13
|
+
# Deforestation around a coordinate (radius 6 km)
|
|
14
|
+
python3 detect.py -s deforestation --lat -3.333 --lon 122.25 -r 6
|
|
15
|
+
|
|
16
|
+
# Same, coordinate as "lat,lon" (quote/`=` because lat is negative)
|
|
17
|
+
python3 detect.py -s mining -l=-3.333,122.25
|
|
18
|
+
|
|
19
|
+
# Flood: baseline window vs event window (both required)
|
|
20
|
+
python3 detect.py -s flood --lat 24.9 --lon 67.9 \
|
|
21
|
+
--pre 2022-06-01:2022-06-30 --post 2022-08-15:2022-09-05
|
|
22
|
+
|
|
23
|
+
# Use a named preset from sites.py instead of a coordinate
|
|
24
|
+
python3 detect.py -s mining --site konawe
|
|
25
|
+
|
|
26
|
+
Outputs (per run):
|
|
27
|
+
images/<scenario>_<product>_<name>.png quick-look
|
|
28
|
+
data/<scenario>_<product>_<name>.tif full-resolution GeoTIFF
|
|
29
|
+
data/<scenario>_<name>_stats.json statistics
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import json
|
|
35
|
+
import uuid
|
|
36
|
+
import argparse
|
|
37
|
+
from datetime import datetime
|
|
38
|
+
|
|
39
|
+
from .gee_utils import (
|
|
40
|
+
download_png, download_geotiff, initialize_ee, square_aoi)
|
|
41
|
+
from .scenarios import SCENARIOS, run_optical_change
|
|
42
|
+
from .indices import (
|
|
43
|
+
INDEX_FN, BUILTUP_METHODS, THERMAL_METHODS, METHOD_DEFAULTS)
|
|
44
|
+
|
|
45
|
+
# Outputs and credentials are resolved against the current working directory,
|
|
46
|
+
# so an installed package writes results where the user runs it (not site-packages).
|
|
47
|
+
OUTPUT_ROOT = os.path.join(os.getcwd(), "output")
|
|
48
|
+
CONFIG_KEY = os.path.join(os.getcwd(), "scripts", "config", "ee-geodetic.json")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def new_run_dir(scenario, name):
|
|
52
|
+
"""Create output/<timestamp>_<scenario>_<name>_<token>/ and return it."""
|
|
53
|
+
run_id = (f"{datetime.now():%Y%m%d-%H%M%S}_{scenario}_{name}"
|
|
54
|
+
f"_{uuid.uuid4().hex[:6]}")
|
|
55
|
+
run_dir = os.path.join(OUTPUT_ROOT, run_id)
|
|
56
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
57
|
+
return run_id, run_dir
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def list_outputs(run_dir):
|
|
61
|
+
"""Print the run folder and everything written to it."""
|
|
62
|
+
print(f"\nAll outputs → output/{os.path.basename(run_dir)}/")
|
|
63
|
+
for f in sorted(os.listdir(run_dir)):
|
|
64
|
+
print(f" {f}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_period(text):
|
|
68
|
+
"""'YYYY-MM-DD:YYYY-MM-DD' -> (start, end)."""
|
|
69
|
+
try:
|
|
70
|
+
start, end = text.split(":")
|
|
71
|
+
return start.strip(), end.strip()
|
|
72
|
+
except ValueError:
|
|
73
|
+
raise SystemExit(f"Bad date window '{text}'. Use START:END "
|
|
74
|
+
"(e.g. 2023-01-01:2023-12-31).")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_location(text):
|
|
78
|
+
"""'lat,lon' -> (lat, lon)."""
|
|
79
|
+
try:
|
|
80
|
+
lat, lon = (float(x) for x in text.split(","))
|
|
81
|
+
return lat, lon
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise SystemExit(f"Bad location '{text}'. Use 'lat,lon' "
|
|
84
|
+
"(e.g. -3.333,122.25).")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def safe_name(text):
|
|
88
|
+
return (text.replace(" ", "_").replace(",", "_")
|
|
89
|
+
.replace(".", "p").replace("-", "m"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def print_scenarios():
|
|
93
|
+
print("Available scenarios (-s):\n")
|
|
94
|
+
for key, cfg in SCENARIOS.items():
|
|
95
|
+
print(f" {key:<14} {cfg['label']}")
|
|
96
|
+
print(f"\nBuilt-up methods (--method) · Sentinel-2: {', '.join(BUILTUP_METHODS)}")
|
|
97
|
+
print(f" · Landsat (thermal, auto): {', '.join(THERMAL_METHODS)}")
|
|
98
|
+
print("\nLocation: --lat LAT --lon LON | -l 'lat,lon' | --site NAME")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_location(args):
|
|
102
|
+
"""Return (lat, lon, radius_km, name)."""
|
|
103
|
+
if args.site:
|
|
104
|
+
from .sites import get_site
|
|
105
|
+
site = get_site(["--site", args.site])
|
|
106
|
+
return site["lat"], site["lon"], site["radius_km"], args.site
|
|
107
|
+
if args.location:
|
|
108
|
+
lat, lon = parse_location(args.location)
|
|
109
|
+
elif args.lat is not None and args.lon is not None:
|
|
110
|
+
lat, lon = args.lat, args.lon
|
|
111
|
+
else:
|
|
112
|
+
raise SystemExit("Provide a location: --lat/--lon, -l 'lat,lon', "
|
|
113
|
+
"or --site NAME. See --help.")
|
|
114
|
+
name = args.name or safe_name(f"{lat}_{lon}")
|
|
115
|
+
return lat, lon, None, name
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_params(scenario, args):
|
|
119
|
+
"""Assemble the params dict a scenario's run() expects."""
|
|
120
|
+
cfg = SCENARIOS[scenario]
|
|
121
|
+
needs = cfg.get("needs")
|
|
122
|
+
p = {}
|
|
123
|
+
|
|
124
|
+
if needs in ("sirad", "epochs"):
|
|
125
|
+
# Both take exactly 3 date windows (R/G/B). --epochs overrides the
|
|
126
|
+
# scenario default; SIRAD stores them as sirad_periods, urban-trend as epochs.
|
|
127
|
+
default = cfg["sirad_periods"] if needs == "sirad" else cfg["epochs"]
|
|
128
|
+
windows = ([parse_period(w) for w in args.epochs.split(",")]
|
|
129
|
+
if args.epochs else default)
|
|
130
|
+
if len(windows) != 3:
|
|
131
|
+
raise SystemExit("--epochs needs exactly 3 windows: W1,W2,W3 "
|
|
132
|
+
"(each START:END, e.g. 2024-01-01:2024-12-31)")
|
|
133
|
+
p["sirad_periods" if needs == "sirad" else "epochs"] = windows
|
|
134
|
+
return p
|
|
135
|
+
|
|
136
|
+
# pre/post windows (optical + flood)
|
|
137
|
+
pre = parse_period(args.pre) if args.pre else cfg.get("pre")
|
|
138
|
+
post = parse_period(args.post) if args.post else cfg.get("post")
|
|
139
|
+
if needs == "pre_post_required" and (not pre or not post):
|
|
140
|
+
raise SystemExit(
|
|
141
|
+
f"Scenario '{scenario}' needs explicit windows: "
|
|
142
|
+
"--pre START:END --post START:END")
|
|
143
|
+
p["pre"], p["post"] = pre, post
|
|
144
|
+
return p
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def apply_overrides(cfg, args):
|
|
148
|
+
"""Return a cfg copy with --method/--thr/--severe applied (optical only)."""
|
|
149
|
+
cfg = dict(cfg)
|
|
150
|
+
if cfg.get("method") != "optical":
|
|
151
|
+
if args.method:
|
|
152
|
+
print(f"(--method ignored — '{args.scenario}' is not index-based)")
|
|
153
|
+
return cfg
|
|
154
|
+
if args.method:
|
|
155
|
+
m = args.method.upper()
|
|
156
|
+
if m not in INDEX_FN:
|
|
157
|
+
raise SystemExit(f"Unknown --method '{args.method}'. "
|
|
158
|
+
f"Options: {', '.join(sorted(INDEX_FN))}")
|
|
159
|
+
direction, thr, severe, vmax = METHOD_DEFAULTS[m]
|
|
160
|
+
sensor = "Landsat" if m in THERMAL_METHODS else "Sentinel-2"
|
|
161
|
+
cfg.update(index=m, direction=direction, thr=thr, severe=severe, vmax=vmax,
|
|
162
|
+
label=f"{args.scenario.capitalize()} — {m} change ({sensor})")
|
|
163
|
+
cfg.setdefault("vmax", METHOD_DEFAULTS.get(cfg["index"], (None, 0, 0, 0.6))[3])
|
|
164
|
+
if args.thr is not None:
|
|
165
|
+
cfg["thr"] = args.thr
|
|
166
|
+
if args.severe is not None:
|
|
167
|
+
cfg["severe"] = args.severe
|
|
168
|
+
return cfg
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _write_gee_product(prod, aoi, run_dir, common, do_map, basemap):
|
|
172
|
+
"""Download one GEE product (png + tif), write its meta, optionally its map."""
|
|
173
|
+
base = f"{common['scenario']}_{prod['key']}_{common['name']}"
|
|
174
|
+
png = os.path.join(run_dir, base + ".png")
|
|
175
|
+
tif = os.path.join(run_dir, base + ".tif")
|
|
176
|
+
print(f"Downloading {prod['key']} PNG...")
|
|
177
|
+
download_png(prod["thumb"], aoi, png, vis=prod["thumb_vis"])
|
|
178
|
+
print(f"Downloading {prod['key']} GeoTIFF...")
|
|
179
|
+
tif_ok = download_geotiff(prod["tif"], aoi, tif, scale=prod.get("scale", 10))
|
|
180
|
+
|
|
181
|
+
is_rgb = "bands" in prod["thumb_vis"]
|
|
182
|
+
vis = dict(prod["thumb_vis"])
|
|
183
|
+
if not is_rgb and "label" not in vis:
|
|
184
|
+
k = prod["key"]
|
|
185
|
+
vis["label"] = ("Δ" + k[1:].upper()) if k.startswith("d") else k.upper()
|
|
186
|
+
meta = {"tif": tif, "product_key": prod["key"], "vis": vis, "is_rgb": is_rgb,
|
|
187
|
+
"metric": vis.get("label"), **common}
|
|
188
|
+
with open(os.path.join(run_dir, base + ".meta.json"), "w") as mf:
|
|
189
|
+
json.dump(meta, mf, indent=2)
|
|
190
|
+
|
|
191
|
+
if do_map and tif_ok:
|
|
192
|
+
from .mapmaker import render_map
|
|
193
|
+
render_map(meta, os.path.join(run_dir, base + "_map"), basemap=basemap)
|
|
194
|
+
elif do_map:
|
|
195
|
+
print(" (map skipped — GeoTIFF unavailable for this product)")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run_gee(args, cfg, lat, lon, radius, name, params, run_dir, run_id, provider, window):
|
|
199
|
+
"""Run the Google Earth Engine backend and write outputs to run_dir."""
|
|
200
|
+
try:
|
|
201
|
+
import ee # noqa: F401 — GEE backend only
|
|
202
|
+
except ImportError:
|
|
203
|
+
sys.exit("The GEE backend needs earthengine-api: "
|
|
204
|
+
"pip install 'satchange[gee]' (or use --backend mpc)")
|
|
205
|
+
initialize_ee(CONFIG_KEY)
|
|
206
|
+
aoi = square_aoi(lon, lat, radius) # square clip (not a circle)
|
|
207
|
+
|
|
208
|
+
if cfg.get("method") == "optical":
|
|
209
|
+
result = run_optical_change(aoi, params, cfg["index"], cfg["direction"],
|
|
210
|
+
cfg["thr"], cfg["severe"], cfg.get("vmax", 0.6))
|
|
211
|
+
else:
|
|
212
|
+
result = cfg["run"](aoi, params)
|
|
213
|
+
|
|
214
|
+
common = {"scenario": args.scenario, "label": cfg["label"], "name": name,
|
|
215
|
+
"run_id": run_id, "source": "Google Earth Engine", "provider": provider,
|
|
216
|
+
"lat": lat, "lon": lon, "radius_km": radius, "window": window,
|
|
217
|
+
"interpretation": result.get("interpretation",
|
|
218
|
+
cfg.get("interpretation", "")),
|
|
219
|
+
"stats": result["stats"]}
|
|
220
|
+
for prod in result["products"]:
|
|
221
|
+
_write_gee_product(prod, aoi, run_dir, common, args.map, args.basemap)
|
|
222
|
+
|
|
223
|
+
stats = {"run_id": run_id, "scenario": args.scenario,
|
|
224
|
+
"location": {"lat": lat, "lon": lon},
|
|
225
|
+
"radius_km": radius, "results": result["stats"]}
|
|
226
|
+
with open(os.path.join(run_dir, "stats.json"), "w") as f:
|
|
227
|
+
json.dump(stats, f, indent=2)
|
|
228
|
+
print("\n=== Results ===")
|
|
229
|
+
print(json.dumps(result["stats"], indent=2))
|
|
230
|
+
print(f"\n{result.get('interpretation', cfg.get('interpretation', ''))}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main():
|
|
234
|
+
ap = argparse.ArgumentParser(
|
|
235
|
+
description="Multipurpose satellite change detection.",
|
|
236
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
237
|
+
epilog=__doc__)
|
|
238
|
+
ap.add_argument("-s", "--scenario", choices=list(SCENARIOS))
|
|
239
|
+
ap.add_argument("-l", "--location", help="'lat,lon' (use -l=-3.3,122.2 if lat<0)")
|
|
240
|
+
ap.add_argument("--lat", type=float)
|
|
241
|
+
ap.add_argument("--lon", type=float)
|
|
242
|
+
ap.add_argument("--site", help="named preset from sites.py")
|
|
243
|
+
ap.add_argument("-r", "--radius", type=float, help="AOI radius in km")
|
|
244
|
+
ap.add_argument("--pre", help="baseline window START:END")
|
|
245
|
+
ap.add_argument("--post", help="recent/event window START:END")
|
|
246
|
+
ap.add_argument("--epochs", help="three date windows W1,W2,W3 (each "
|
|
247
|
+
"START:END) for urban-trend epochs OR mining SIRAD periods "
|
|
248
|
+
"(R/G/B), e.g. 2024-01-01:2024-12-31,2025-...,2026-...")
|
|
249
|
+
ap.add_argument("-n", "--name", help="output label (default from coords)")
|
|
250
|
+
ap.add_argument("--map", action="store_true",
|
|
251
|
+
help="also render an A4 map layout (PDF + PNG) per product")
|
|
252
|
+
ap.add_argument("--basemap", choices=["osm", "gray", "none"], default="osm",
|
|
253
|
+
help="map basemap (default osm)")
|
|
254
|
+
ap.add_argument("--backend", choices=["gee", "mpc"], default="gee",
|
|
255
|
+
help="data backend: gee (Earth Engine) or mpc "
|
|
256
|
+
"(Microsoft Planetary Computer, no account needed)")
|
|
257
|
+
ap.add_argument("--method", help="override the index for optical scenarios "
|
|
258
|
+
"(e.g. urbanization: NDBI|UI|BU|IBI; also NDVI/NDWI/NBR)")
|
|
259
|
+
ap.add_argument("--thr", type=float, help="override the 'affected' threshold")
|
|
260
|
+
ap.add_argument("--severe", type=float, help="override the 'severe' threshold")
|
|
261
|
+
ap.add_argument("--list", action="store_true", help="list scenarios and exit")
|
|
262
|
+
args = ap.parse_args()
|
|
263
|
+
|
|
264
|
+
if args.list or not args.scenario:
|
|
265
|
+
print_scenarios()
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
cfg = SCENARIOS[args.scenario]
|
|
269
|
+
lat, lon, site_radius, name = resolve_location(args)
|
|
270
|
+
radius = args.radius or site_radius or cfg["radius"]
|
|
271
|
+
params = build_params(args.scenario, args)
|
|
272
|
+
|
|
273
|
+
cfg = apply_overrides(cfg, args)
|
|
274
|
+
|
|
275
|
+
print(f"=== Change detection: {args.scenario} ===")
|
|
276
|
+
print(f"{cfg['label']}")
|
|
277
|
+
print(f"Location: {lat}, {lon} radius {radius} km [{name}]\n")
|
|
278
|
+
|
|
279
|
+
# temporal window label for the map subtitle (both backends)
|
|
280
|
+
if params.get("pre"):
|
|
281
|
+
window = f"{params['pre'][0]} → {params['post'][1]}"
|
|
282
|
+
elif params.get("sirad_periods"):
|
|
283
|
+
sp = params["sirad_periods"]
|
|
284
|
+
window = f"{sp[0][0]} → {sp[-1][1]}"
|
|
285
|
+
elif params.get("epochs"):
|
|
286
|
+
ep = params["epochs"]
|
|
287
|
+
window = " · ".join(w[0][:4] for w in ep)
|
|
288
|
+
else:
|
|
289
|
+
window = None
|
|
290
|
+
|
|
291
|
+
landsat = cfg.get("method") == "trend" or cfg.get("index") in THERMAL_METHODS
|
|
292
|
+
provider = "Landsat C2-L2 (USGS/NASA)" if landsat else "Copernicus Sentinel (ESA)"
|
|
293
|
+
|
|
294
|
+
run_id, run_dir = new_run_dir(args.scenario, name)
|
|
295
|
+
print(f"Output folder: output/{run_id}/\n")
|
|
296
|
+
|
|
297
|
+
if args.backend == "mpc":
|
|
298
|
+
from .mpc_backend import run_mpc
|
|
299
|
+
run_mpc(args.scenario, cfg, lat, lon, radius, name, params,
|
|
300
|
+
run_dir, run_id, window, provider,
|
|
301
|
+
do_map=args.map, basemap=args.basemap)
|
|
302
|
+
list_outputs(run_dir)
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
run_gee(args, cfg, lat, lon, radius, name, params,
|
|
306
|
+
run_dir, run_id, provider, window)
|
|
307
|
+
list_outputs(run_dir)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
main()
|
satchange/gee_utils.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Helpers to download Earth Engine results directly to local disk.
|
|
3
|
+
|
|
4
|
+
Two ways to get a result out of GEE:
|
|
5
|
+
* download_png() — a quick-look RGB thumbnail (capped resolution)
|
|
6
|
+
* download_geotiff() — the full-resolution, georeferenced GeoTIFF you can
|
|
7
|
+
open in QGIS/rasterio. Uses ee.Image.getDownloadURL,
|
|
8
|
+
which has a per-request size limit (~32-48 MB); for
|
|
9
|
+
very large AOIs, fall back to a Drive export.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fetch(url, out_path):
|
|
18
|
+
resp = requests.get(url, stream=True)
|
|
19
|
+
resp.raise_for_status()
|
|
20
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
21
|
+
with open(out_path, "wb") as f:
|
|
22
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
23
|
+
f.write(chunk)
|
|
24
|
+
return out_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def download_png(image, region, out_path, dimensions="1920x1920", vis=None):
|
|
28
|
+
"""Download an RGB quick-look PNG thumbnail."""
|
|
29
|
+
params = {"region": region, "dimensions": dimensions, "format": "png"}
|
|
30
|
+
if vis:
|
|
31
|
+
params.update(vis)
|
|
32
|
+
_fetch(image.getThumbURL(params), out_path)
|
|
33
|
+
print(f"Saved: {os.path.normpath(out_path)}")
|
|
34
|
+
return out_path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def download_geotiff(image, region, out_path, scale=10, max_scale_mult=16):
|
|
38
|
+
"""Download a full-resolution single-file GeoTIFF.
|
|
39
|
+
|
|
40
|
+
Earth Engine's direct download has a per-request size/compute limit, so a
|
|
41
|
+
large AOI can 400. Retry at progressively coarser scale until it fits.
|
|
42
|
+
Returns the path on success, or None if it fails even coarsened.
|
|
43
|
+
"""
|
|
44
|
+
s, mult = scale, 1
|
|
45
|
+
last_err = None
|
|
46
|
+
while mult <= max_scale_mult:
|
|
47
|
+
try:
|
|
48
|
+
url = image.getDownloadURL({
|
|
49
|
+
"region": region, "scale": s,
|
|
50
|
+
"format": "GEO_TIFF", "filePerBand": False,
|
|
51
|
+
})
|
|
52
|
+
_fetch(url, out_path) # the pixel fetch can also fail for large AOIs
|
|
53
|
+
size_mb = os.path.getsize(out_path) / 1e6
|
|
54
|
+
note = f" (coarsened to {s:.0f} m to fit)" if s != scale else ""
|
|
55
|
+
print(f"Saved: {os.path.normpath(out_path)} ({size_mb:.1f} MB GeoTIFF){note}")
|
|
56
|
+
return out_path
|
|
57
|
+
except Exception as e: # noqa: BLE001 — retry coarser
|
|
58
|
+
last_err = e
|
|
59
|
+
mult *= 2
|
|
60
|
+
s = scale * mult
|
|
61
|
+
print(f"NOTE: GeoTIFF download failed even at {scale * max_scale_mult:.0f} m "
|
|
62
|
+
f"({last_err}).")
|
|
63
|
+
print(" Reduce --radius, or add --drive to export the full-res GeoTIFF "
|
|
64
|
+
"to Google Drive.")
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def wants_drive_export(argv=None):
|
|
69
|
+
"""True if the user passed --drive (opt-in full-res Drive export)."""
|
|
70
|
+
argv = sys.argv if argv is None else argv
|
|
71
|
+
return "--drive" in argv
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def square_aoi(lon, lat, radius_km):
|
|
75
|
+
"""Square AOI centred on (lon, lat), half-side = radius_km.
|
|
76
|
+
|
|
77
|
+
Side length = 2 * radius_km (the square that circumscribes the old circle),
|
|
78
|
+
axis-aligned in lon/lat. Use instead of Point.buffer() (a circle).
|
|
79
|
+
"""
|
|
80
|
+
import ee
|
|
81
|
+
return ee.Geometry.Point([lon, lat]).buffer(radius_km * 1000).bounds()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def mask_s2_clouds(img):
|
|
85
|
+
"""Mask cloud / shadow / cirrus / snow using Sentinel-2 SCL band.
|
|
86
|
+
|
|
87
|
+
Applied per pixel so a median of many scenes yields a near cloud-free
|
|
88
|
+
composite even when individual scenes are partly cloudy.
|
|
89
|
+
"""
|
|
90
|
+
scl = img.select("SCL")
|
|
91
|
+
keep = (scl.neq(3) # cloud shadow
|
|
92
|
+
.And(scl.neq(8)) # cloud medium probability
|
|
93
|
+
.And(scl.neq(9)) # cloud high probability
|
|
94
|
+
.And(scl.neq(10)) # thin cirrus
|
|
95
|
+
.And(scl.neq(11))) # snow / ice
|
|
96
|
+
return img.updateMask(keep)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def initialize_ee(config_key=None):
|
|
100
|
+
"""Initialise Earth Engine with a service-account key if one is present."""
|
|
101
|
+
import json
|
|
102
|
+
import ee
|
|
103
|
+
|
|
104
|
+
candidates = [
|
|
105
|
+
p for p in (config_key,
|
|
106
|
+
os.path.expanduser("~/.config/earthengine/ee-geodetic.json"))
|
|
107
|
+
if p
|
|
108
|
+
]
|
|
109
|
+
for key_path in candidates:
|
|
110
|
+
if os.path.exists(key_path):
|
|
111
|
+
with open(key_path) as f:
|
|
112
|
+
email = json.load(f).get("client_email")
|
|
113
|
+
ee.Initialize(ee.ServiceAccountCredentials(email, key_file=key_path))
|
|
114
|
+
print(f"GEE: service account {email}")
|
|
115
|
+
return
|
|
116
|
+
ee.Initialize() # falls back to `earthengine authenticate`
|
|
117
|
+
print("GEE: user credentials (earthengine authenticate)")
|
satchange/indices.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Spectral indices, composites, and Sentinel-1 helpers for change detection.
|
|
3
|
+
|
|
4
|
+
All functions operate server-side in Google Earth Engine. Sentinel-2 composites
|
|
5
|
+
mask cloud per pixel (SCL) and take a median over many scenes, so the result is
|
|
6
|
+
near cloud-free regardless of any single scene's cloud cover.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import ee # only needed for the GEE backend
|
|
11
|
+
except ImportError:
|
|
12
|
+
ee = None
|
|
13
|
+
from .gee_utils import mask_s2_clouds
|
|
14
|
+
|
|
15
|
+
S2 = "COPERNICUS/S2_SR_HARMONIZED"
|
|
16
|
+
S1 = "COPERNICUS/S1_GRD"
|
|
17
|
+
ORBITS = ("ASCENDING", "DESCENDING")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def s2_median(aoi, start, end, scene_cloud_max=60):
|
|
21
|
+
"""Cloud-masked median Sentinel-2 SR composite over a date window.
|
|
22
|
+
|
|
23
|
+
Returns (image, scene_count). scene_count is a Python int.
|
|
24
|
+
"""
|
|
25
|
+
coll = (ee.ImageCollection(S2)
|
|
26
|
+
.filterBounds(aoi)
|
|
27
|
+
.filterDate(start, end)
|
|
28
|
+
.filter(ee.Filter.lte("CLOUDY_PIXEL_PERCENTAGE", scene_cloud_max))
|
|
29
|
+
.map(mask_s2_clouds))
|
|
30
|
+
return coll.median(), coll.size().getInfo()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --- Normalised-difference indices on a Sentinel-2 SR image ---
|
|
34
|
+
def ndvi(img): # vegetation
|
|
35
|
+
return img.normalizedDifference(["B8", "B4"]).rename("NDVI")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ndbi(img): # built-up
|
|
39
|
+
return img.normalizedDifference(["B11", "B8"]).rename("NDBI")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ndwi(img): # open water (McFeeters)
|
|
43
|
+
return img.normalizedDifference(["B3", "B8"]).rename("NDWI")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def nbr(img): # burn
|
|
47
|
+
return img.normalizedDifference(["B8", "B12"]).rename("NBR")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- Alternative built-up indices (all computable on Sentinel-2) ---
|
|
51
|
+
def ui(img): # Urban Index — (SWIR2-NIR)/(SWIR2+NIR)
|
|
52
|
+
return img.normalizedDifference(["B12", "B8"]).rename("UI")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _savi(img, L=0.5): # Soil-Adjusted Vegetation Index (reflectance-scaled)
|
|
56
|
+
nir = img.select("B8").divide(10000)
|
|
57
|
+
red = img.select("B4").divide(10000)
|
|
58
|
+
return nir.subtract(red).multiply(1 + L).divide(nir.add(red).add(L))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def bu(img): # Built-Up index = NDBI - NDVI (Kawamura 1996)
|
|
62
|
+
return ndbi(img).subtract(ndvi(img)).rename("BU")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ibi(img): # Index-Based Built-up Index (Xu 2008)
|
|
66
|
+
nd = img.normalizedDifference(["B11", "B8"]) # NDBI
|
|
67
|
+
mndwi = img.normalizedDifference(["B3", "B11"]) # water
|
|
68
|
+
x = _savi(img).add(mndwi).divide(2)
|
|
69
|
+
# The ratio form is unstable where the denominator crosses zero; clamp it.
|
|
70
|
+
return nd.subtract(x).divide(nd.add(x)).clamp(-1, 1).rename("IBI")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
INDEX_FN = {"NDVI": ndvi, "NDBI": ndbi, "NDWI": ndwi, "NBR": nbr,
|
|
74
|
+
"UI": ui, "BU": bu, "IBI": ibi}
|
|
75
|
+
# Interchangeable built-up methods for the urbanization scenario (Sentinel-2).
|
|
76
|
+
# NDISI/EBBI need a thermal band (Landsat), so they are NOT listed here.
|
|
77
|
+
BUILTUP_METHODS = ["NDBI", "UI", "BU", "IBI"]
|
|
78
|
+
|
|
79
|
+
# Per-method change-detection defaults: (direction, affected_thr, severe_thr, vmax).
|
|
80
|
+
# Different indices have different ranges, so each needs its own thresholds.
|
|
81
|
+
METHOD_DEFAULTS = {
|
|
82
|
+
"NDVI": ("loss", -0.15, -0.30, 0.6),
|
|
83
|
+
"NDBI": ("gain", 0.10, 0.20, 0.5),
|
|
84
|
+
"UI": ("gain", 0.08, 0.18, 0.5),
|
|
85
|
+
"BU": ("gain", 0.10, 0.25, 0.8),
|
|
86
|
+
"IBI": ("gain", 0.10, 0.25, 1.0),
|
|
87
|
+
"NDWI": ("gain", 0.10, 0.25, 0.6),
|
|
88
|
+
"NBR": ("loss", -0.10, -0.27, 0.6),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- Landsat 8/9 Collection-2 Level-2 (adds a thermal band for NDISI/EBBI) ---
|
|
93
|
+
L8_COL, L9_COL = "LANDSAT/LC08/C02/T1_L2", "LANDSAT/LC09/C02/T1_L2"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _l2_prep(img):
|
|
97
|
+
"""Scale a Landsat C2-L2 scene to reflectance + °C and mask clouds."""
|
|
98
|
+
qa = img.select("QA_PIXEL")
|
|
99
|
+
clear = qa.bitwiseAnd((1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4)).eq(0)
|
|
100
|
+
opt = (img.select(["SR_B3", "SR_B4", "SR_B5", "SR_B6", "SR_B7"])
|
|
101
|
+
.multiply(0.0000275).add(-0.2))
|
|
102
|
+
tir = img.select("ST_B10").multiply(0.00341802).add(149.0).subtract(273.15)
|
|
103
|
+
return (opt.addBands(tir)
|
|
104
|
+
.rename(["GREEN", "RED", "NIR", "SWIR1", "SWIR2", "TIR"])
|
|
105
|
+
.updateMask(clear))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def l2_median(aoi, start, end, cloud_max=60):
|
|
109
|
+
"""Cloud-masked median Landsat 8/9 composite. Returns (image, scene_count)."""
|
|
110
|
+
col = (ee.ImageCollection(L8_COL).merge(ee.ImageCollection(L9_COL))
|
|
111
|
+
.filterBounds(aoi).filterDate(start, end)
|
|
112
|
+
.filter(ee.Filter.lt("CLOUD_COVER", cloud_max)).map(_l2_prep))
|
|
113
|
+
return col.median(), col.size().getInfo()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _prep_l_sr(img, bands):
|
|
117
|
+
"""Scale a Landsat C2-L2 scene to surface reflectance (no thermal), masked."""
|
|
118
|
+
qa = img.select("QA_PIXEL")
|
|
119
|
+
clear = qa.bitwiseAnd((1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4)).eq(0)
|
|
120
|
+
opt = img.select(bands).multiply(0.0000275).add(-0.2)
|
|
121
|
+
return opt.rename(["GREEN", "RED", "NIR", "SWIR1", "SWIR2"]).updateMask(clear)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def l_sr_median(aoi, start, end, cloud_max=60):
|
|
125
|
+
"""Median Landsat surface-reflectance composite (archive back to 1984).
|
|
126
|
+
|
|
127
|
+
Uses Landsat 5 (TM) and 8/9 (OLI); Landsat 7 is EXCLUDED because its
|
|
128
|
+
Scan Line Corrector failed in 2003 (SLC-off gaps stripe every scene).
|
|
129
|
+
Uniform band naming across sensors so historical epochs (e.g. 2010) work.
|
|
130
|
+
"""
|
|
131
|
+
tm = (ee.ImageCollection("LANDSAT/LT05/C02/T1_L2") # L5 TM (no SLC-off; ends 2011)
|
|
132
|
+
.filterBounds(aoi).filterDate(start, end)
|
|
133
|
+
.filter(ee.Filter.lt("CLOUD_COVER", cloud_max))
|
|
134
|
+
.map(lambda i: _prep_l_sr(i, ["SR_B2", "SR_B3", "SR_B4", "SR_B5", "SR_B7"])))
|
|
135
|
+
oli = (ee.ImageCollection("LANDSAT/LC08/C02/T1_L2")
|
|
136
|
+
.merge(ee.ImageCollection("LANDSAT/LC09/C02/T1_L2"))
|
|
137
|
+
.filterBounds(aoi).filterDate(start, end)
|
|
138
|
+
.filter(ee.Filter.lt("CLOUD_COVER", cloud_max))
|
|
139
|
+
.map(lambda i: _prep_l_sr(i, ["SR_B3", "SR_B4", "SR_B5", "SR_B6", "SR_B7"])))
|
|
140
|
+
col = tm.merge(oli)
|
|
141
|
+
return col.median(), col.size().getInfo()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _norm01(band, lo, hi):
|
|
145
|
+
return band.subtract(lo).divide(hi - lo).clamp(0, 1)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def ndisi(img): # Normalized Difference Impervious Surface Index (Xu 2010)
|
|
149
|
+
tir = _norm01(img.select("TIR"), 0, 50) # °C -> [0,1]
|
|
150
|
+
nir = img.select("NIR").clamp(0, 1)
|
|
151
|
+
swir1 = img.select("SWIR1").clamp(0, 1)
|
|
152
|
+
mndwi = img.normalizedDifference(["GREEN", "SWIR1"]).add(1).divide(2) # ->[0,1]
|
|
153
|
+
x = mndwi.add(nir).add(swir1).divide(3)
|
|
154
|
+
return tir.subtract(x).divide(tir.add(x)).clamp(-1, 1).rename("NDISI")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ebbi(img): # Enhanced Built-up & Bareness Index (As-syakur 2012), x100
|
|
158
|
+
swir1, nir, tir = img.select("SWIR1"), img.select("NIR"), img.select("TIR")
|
|
159
|
+
denom = swir1.add(tir).max(1e-6).sqrt().multiply(10)
|
|
160
|
+
return swir1.subtract(nir).divide(denom).multiply(100).rename("EBBI")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
INDEX_FN.update({"NDISI": ndisi, "EBBI": ebbi})
|
|
164
|
+
|
|
165
|
+
# Which sensor each index needs. Thermal indices require Landsat (not Sentinel-2).
|
|
166
|
+
SENSOR = {k: "S2" for k in ("NDVI", "NDBI", "NDWI", "NBR", "UI", "BU", "IBI")}
|
|
167
|
+
SENSOR.update({"NDISI": "L8", "EBBI": "L8"})
|
|
168
|
+
THERMAL_METHODS = ["NDISI", "EBBI"] # Landsat-only built-up/impervious methods
|
|
169
|
+
|
|
170
|
+
METHOD_DEFAULTS.update({
|
|
171
|
+
"NDISI": ("gain", 0.05, 0.12, 0.5),
|
|
172
|
+
"EBBI": ("gain", 0.10, 0.25, 1.0),
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --- Sentinel-1 SAR helpers ---
|
|
177
|
+
def s1(aoi, start, end, orbit, pol):
|
|
178
|
+
"""Sentinel-1 IW collection for one polarisation and orbit direction."""
|
|
179
|
+
return (ee.ImageCollection(S1)
|
|
180
|
+
.filterBounds(aoi)
|
|
181
|
+
.filterDate(start, end)
|
|
182
|
+
.filter(ee.Filter.listContains("transmitterReceiverPolarisation", pol))
|
|
183
|
+
.filter(ee.Filter.eq("instrumentMode", "IW"))
|
|
184
|
+
.filter(ee.Filter.eq("orbitProperties_pass", orbit))
|
|
185
|
+
.select(pol))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def best_orbit(aoi, periods, pol="VH", forced=None):
|
|
189
|
+
"""Pick the orbit direction (ASC/DESC) that has imagery in every period.
|
|
190
|
+
|
|
191
|
+
`periods` is a list of (start, end) tuples. Returns (orbit, covered, counts).
|
|
192
|
+
"""
|
|
193
|
+
orbits = [forced] if forced else list(ORBITS)
|
|
194
|
+
best = None # (covered, total, orbit, counts)
|
|
195
|
+
for orbit in orbits:
|
|
196
|
+
counts = [s1(aoi, s, e, orbit, pol).size().getInfo() for (s, e) in periods]
|
|
197
|
+
cand = (all(c > 0 for c in counts), sum(counts), orbit, counts)
|
|
198
|
+
if best is None or cand[:2] > best[:2]:
|
|
199
|
+
best = cand
|
|
200
|
+
covered, _total, orbit, counts = best
|
|
201
|
+
return orbit, covered, counts
|