earthdiff 0.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.
- earthdiff/__init__.py +28 -0
- earthdiff/__main__.py +3 -0
- earthdiff/aoi.py +161 -0
- earthdiff/cause.py +144 -0
- earthdiff/change.py +337 -0
- earthdiff/cli.py +202 -0
- earthdiff/composite.py +169 -0
- earthdiff/geocode.py +186 -0
- earthdiff/history.py +86 -0
- earthdiff/i18n.py +201 -0
- earthdiff/indices.py +52 -0
- earthdiff/mcp_prompts.py +54 -0
- earthdiff/mcp_server.py +338 -0
- earthdiff/mcp_service.py +887 -0
- earthdiff/periods.py +87 -0
- earthdiff/pipeline.py +476 -0
- earthdiff/reader.py +59 -0
- earthdiff/render.py +146 -0
- earthdiff/report.py +113 -0
- earthdiff/safety.py +93 -0
- earthdiff/seasonal.py +155 -0
- earthdiff/stac.py +163 -0
- earthdiff/static/ui.html +239 -0
- earthdiff/templates/report.html +850 -0
- earthdiff/terrain.py +120 -0
- earthdiff/ui.py +227 -0
- earthdiff/vendor/LEAFLET-LICENSE.txt +26 -0
- earthdiff/vendor/leaflet.css +661 -0
- earthdiff/vendor/leaflet.js +6 -0
- earthdiff/watch.py +138 -0
- earthdiff/xlsx.py +183 -0
- earthdiff/yearly.py +109 -0
- earthdiff-0.2.0.dist-info/METADATA +368 -0
- earthdiff-0.2.0.dist-info/RECORD +37 -0
- earthdiff-0.2.0.dist-info/WHEEL +4 -0
- earthdiff-0.2.0.dist-info/entry_points.txt +3 -0
- earthdiff-0.2.0.dist-info/licenses/LICENSE +21 -0
earthdiff/cli.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Command line interface: run | plan | watch | ui | mcp."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import date
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .aoi import AOIError, area_km2, load_aoi, parse_bbox
|
|
11
|
+
from .aoi import MAX_AREA_KM2
|
|
12
|
+
from .change import SENSITIVITY
|
|
13
|
+
from .periods import parse_period
|
|
14
|
+
from .pipeline import InsufficientDataError, RunOptions, plan, run_analysis
|
|
15
|
+
from .stac import BACKENDS, STACError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _say(msg: str) -> None:
|
|
19
|
+
print(f" {msg}", flush=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _add_common(p: argparse.ArgumentParser) -> None:
|
|
23
|
+
g = p.add_argument_group("area of interest")
|
|
24
|
+
g.add_argument("--aoi", help="GeoJSON file (Polygon/MultiPolygon, Feature or FeatureCollection; WGS84)")
|
|
25
|
+
g.add_argument("--bbox", help="west,south,east,north in degrees (alternative to --aoi)")
|
|
26
|
+
g = p.add_argument_group("time windows")
|
|
27
|
+
g.add_argument("--after", required=True, help="YYYY-MM | YYYY-MM-DD | START/END (the 'now' window)")
|
|
28
|
+
g.add_argument("--before", help="baseline window; default = the --after window one year earlier (same season)")
|
|
29
|
+
g = p.add_argument_group("processing")
|
|
30
|
+
g.add_argument("--backend", choices=list(BACKENDS), default="earth-search", help="free public STAC API (default: earth-search)")
|
|
31
|
+
g.add_argument("--resolution", type=float, help="analysis pixel size in metres (default: auto 10/20/30/60)")
|
|
32
|
+
g.add_argument("--max-scenes", type=int, default=8, help="max scenes per composite (default 8)")
|
|
33
|
+
g.add_argument("--max-cloud", type=float, default=80.0, help="discard scenes with more scene-level cloud %% (default 80)")
|
|
34
|
+
g.add_argument("--sensitivity", choices=list(SENSITIVITY), default="default")
|
|
35
|
+
g.add_argument("--mmu-ha", type=float, default=0.5, help="minimum mapping unit in hectares (default 0.5)")
|
|
36
|
+
g.add_argument("--max-area-km2", type=float, default=MAX_AREA_KM2, help=f"AOI size limit (default {MAX_AREA_KM2:,.0f} km2, the same limit as the web UI and the MCP server)")
|
|
37
|
+
g.add_argument("--no-likely-cause", action="store_true", help="do not read B12 / label vegetation loss as fire-like, clearing-like or unclear (dNBR)")
|
|
38
|
+
g.add_argument("--any-season", action="store_true", help="run even when the two windows are in different seasons (refused by default: almost everything would look changed)")
|
|
39
|
+
g.add_argument("--no-yearly-frames", action="store_true", help="windows years apart: do not add one small timeline frame per in-between year")
|
|
40
|
+
g.add_argument("--no-terrain", action="store_true", help="do not read the Copernicus DEM for the report's offline 3D view and relief basemap")
|
|
41
|
+
g.add_argument("--seasonal-check", action="store_true", help="compare vegetation loss with the same windows of earlier years and flag recurring (harvest-like) loss; adds a few small downloads")
|
|
42
|
+
g.add_argument("--seasonal-years", type=int, default=2, choices=[1, 2, 3], help="how many earlier years the seasonal check looks at (default 2)")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _geometry(args):
|
|
46
|
+
if bool(args.aoi) == bool(args.bbox):
|
|
47
|
+
raise AOIError("give exactly one of --aoi or --bbox")
|
|
48
|
+
return load_aoi(args.aoi) if args.aoi else parse_bbox(args.bbox)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _options(args, name: str) -> RunOptions:
|
|
52
|
+
return RunOptions(
|
|
53
|
+
name=name,
|
|
54
|
+
backend=args.backend,
|
|
55
|
+
resolution=args.resolution,
|
|
56
|
+
max_scenes=args.max_scenes,
|
|
57
|
+
max_cloud=args.max_cloud,
|
|
58
|
+
sensitivity=args.sensitivity,
|
|
59
|
+
mmu_ha=args.mmu_ha,
|
|
60
|
+
max_area_km2=args.max_area_km2,
|
|
61
|
+
force=getattr(args, "force", False),
|
|
62
|
+
likely_cause=not args.no_likely_cause,
|
|
63
|
+
terrain=not args.no_terrain,
|
|
64
|
+
allow_season_mismatch=args.any_season,
|
|
65
|
+
yearly_frames=not args.no_yearly_frames,
|
|
66
|
+
seasonal_check=args.seasonal_check,
|
|
67
|
+
seasonal_years=args.seasonal_years,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _periods(args):
|
|
72
|
+
after = parse_period(args.after)
|
|
73
|
+
before = parse_period(args.before) if args.before else after.shift_years(-1)
|
|
74
|
+
return before, after
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_run(args) -> int:
|
|
78
|
+
geom = _geometry(args)
|
|
79
|
+
before, after = _periods(args)
|
|
80
|
+
name = args.name or (Path(args.aoi).stem if args.aoi else "bbox area")
|
|
81
|
+
print(f"earthdiff {__version__}: {name} | before {before.label()} -> after {after.label()}")
|
|
82
|
+
res = run_analysis(geom, before, after, args.out, _options(args, name), progress=_say)
|
|
83
|
+
print_summary(res.summary)
|
|
84
|
+
print(f"\nOpen: {res.files['report']}")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_plan(args) -> int:
|
|
89
|
+
geom = _geometry(args)
|
|
90
|
+
before, after = _periods(args)
|
|
91
|
+
info = plan(geom, before, after, _options(args, "plan"))
|
|
92
|
+
print(f"AOI {info['aoi_km2']} km2, analysis resolution {info['resolution_m']:g} m")
|
|
93
|
+
print(f"before {before.label()}: {info['before_candidates']} candidate scenes {', '.join(info['before_dates'][:12])}")
|
|
94
|
+
print(f"after {after.label()}: {info['after_candidates']} candidate scenes {', '.join(info['after_dates'][:12])}")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def print_summary(s: dict) -> None:
|
|
99
|
+
print("\nResult")
|
|
100
|
+
print(f" comparable (cloud-free in both periods): {s['valid_fraction'] * 100:.0f}% of the AOI")
|
|
101
|
+
for t in s["totals"].values():
|
|
102
|
+
print(f" {t['label']:<26} {t['area_ha']:>10,.1f} ha ({t['polygons']} polygons)")
|
|
103
|
+
lc = s.get("likely_cause")
|
|
104
|
+
if lc:
|
|
105
|
+
t = lc["totals"]
|
|
106
|
+
print(" vegetation loss, likely cause (a hint from dNBR, brightness and shape, not a verdict): "
|
|
107
|
+
+ ", ".join(f"{k} {t[k]['area_ha']:,.1f} ha" for k in ("fire-like", "clearing-like", "unclear")))
|
|
108
|
+
sc = s.get("seasonal_check")
|
|
109
|
+
if sc and sc.get("enabled"):
|
|
110
|
+
ls = sc["likely_seasonal"]
|
|
111
|
+
print(f" seasonal check ({sc['years']} earlier year(s), {sc['extra_composites']} extra composites, {sc.get('elapsed_s', 0)} s): likely seasonal {ls['area_ha']:,.1f} ha in {ls['polygons']} polygons")
|
|
112
|
+
for w in s["warnings"]:
|
|
113
|
+
print(f" ! {w}")
|
|
114
|
+
print(f" {s['attribution']}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_watch(args) -> int:
|
|
118
|
+
from .watch import EXAMPLE, run_watch
|
|
119
|
+
|
|
120
|
+
if args.init:
|
|
121
|
+
p = Path(args.config)
|
|
122
|
+
if p.exists():
|
|
123
|
+
print(f"{p} already exists; not overwriting", file=sys.stderr)
|
|
124
|
+
return 2
|
|
125
|
+
p.write_text(EXAMPLE, encoding="utf-8")
|
|
126
|
+
print(f"wrote example config to {p}")
|
|
127
|
+
return 0
|
|
128
|
+
today = date.fromisoformat(args.today) if args.today else None
|
|
129
|
+
outcomes = run_watch(args.config, today=today, force=args.force, progress=_say)
|
|
130
|
+
failed = 0
|
|
131
|
+
for o in outcomes:
|
|
132
|
+
extra = ""
|
|
133
|
+
if o.delta:
|
|
134
|
+
extra = " (vs previous run: " + ", ".join(f"{k} {v:+.1f} ha" for k, v in o.delta.items() if v) + ")"
|
|
135
|
+
print(f"[{o.status}] {o.name}: {o.message}{extra}")
|
|
136
|
+
failed += o.status == "failed"
|
|
137
|
+
return 3 if failed else 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_ui(args) -> int:
|
|
141
|
+
from .ui import serve
|
|
142
|
+
|
|
143
|
+
serve(port=args.port, workdir=Path(args.workdir), open_browser=not args.no_browser)
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
148
|
+
p = argparse.ArgumentParser(prog="earthdiff", description="What changed here? Explainable change reports from free Sentinel-2 imagery.")
|
|
149
|
+
p.add_argument("--version", action="version", version=f"earthdiff {__version__}")
|
|
150
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
151
|
+
|
|
152
|
+
r = sub.add_parser("run", help="analyse one area and write a report folder")
|
|
153
|
+
_add_common(r)
|
|
154
|
+
r.add_argument("--out", default="report", help="output folder (default ./report)")
|
|
155
|
+
r.add_argument("--name", help="title shown in the report")
|
|
156
|
+
r.add_argument("--force", action="store_true", help="run even when the cloud-free fraction is very low")
|
|
157
|
+
r.set_defaults(fn=cmd_run)
|
|
158
|
+
|
|
159
|
+
pl = sub.add_parser("plan", help="dry run: only search scenes, download nothing")
|
|
160
|
+
_add_common(pl)
|
|
161
|
+
pl.set_defaults(fn=cmd_plan)
|
|
162
|
+
|
|
163
|
+
w = sub.add_parser("watch", help="re-run the areas of a TOML config for the last full month (schedule it yourself)")
|
|
164
|
+
w.add_argument("config", help="path of the TOML file")
|
|
165
|
+
w.add_argument("--init", action="store_true", help="write an example config to the given path and exit")
|
|
166
|
+
w.add_argument("--today", help="pretend today is YYYY-MM-DD (testing/backfill)")
|
|
167
|
+
w.add_argument("--force", action="store_true", help="redo periods that already have results")
|
|
168
|
+
w.set_defaults(fn=cmd_watch)
|
|
169
|
+
|
|
170
|
+
sub.add_parser("mcp", help="MCP server (stdio) for AI assistants; same as `earthdiff-mcp` (needs: pip install \"earthdiff[mcp]\")")
|
|
171
|
+
|
|
172
|
+
u = sub.add_parser("ui", help="local web UI (127.0.0.1 only): draw an area, pick dates, run")
|
|
173
|
+
u.add_argument("--port", type=int, default=8765)
|
|
174
|
+
u.add_argument("--workdir", default="earthdiff-runs", help="where UI runs are stored")
|
|
175
|
+
u.add_argument("--no-browser", action="store_true")
|
|
176
|
+
u.set_defaults(fn=cmd_ui)
|
|
177
|
+
return p
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main(argv: list[str] | None = None) -> int:
|
|
181
|
+
raw = sys.argv[1:] if argv is None else argv
|
|
182
|
+
if raw and raw[0] == "mcp": # `earthdiff mcp [options]` is the same server as `earthdiff-mcp`
|
|
183
|
+
from .mcp_server import main as mcp_main
|
|
184
|
+
|
|
185
|
+
mcp_main(raw[1:])
|
|
186
|
+
return 0
|
|
187
|
+
args = build_parser().parse_args(argv)
|
|
188
|
+
try:
|
|
189
|
+
return args.fn(args)
|
|
190
|
+
except (AOIError, ValueError) as exc:
|
|
191
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
192
|
+
return 2
|
|
193
|
+
except (InsufficientDataError, STACError) as exc:
|
|
194
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
195
|
+
return 3
|
|
196
|
+
except KeyboardInterrupt:
|
|
197
|
+
print("interrupted", file=sys.stderr)
|
|
198
|
+
return 130
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
raise SystemExit(main())
|
earthdiff/composite.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Cloud masking (SCL) and per-period median composites."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import gc
|
|
5
|
+
import warnings
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy.ndimage import binary_dilation
|
|
12
|
+
|
|
13
|
+
from .aoi import Grid
|
|
14
|
+
from .reader import cog_reader
|
|
15
|
+
from .stac import Scene
|
|
16
|
+
|
|
17
|
+
BANDS = ("blue", "green", "red", "nir", "swir16")
|
|
18
|
+
BANDS_WITH_B12 = BANDS + ("swir22",) # swir22 (B12) feeds NBR / dNBR; it is optional: an unreadable B12 never blocks a run
|
|
19
|
+
OPTIONAL_BANDS = ("swir22",)
|
|
20
|
+
|
|
21
|
+
# Sentinel-2 L2A Scene Classification Layer values
|
|
22
|
+
SCL_NODATA, SCL_SATURATED, SCL_DARK, SCL_CLOUD_SHADOW = 0, 1, 2, 3
|
|
23
|
+
SCL_CLOUD_MEDIUM, SCL_CLOUD_HIGH, SCL_CIRRUS, SCL_SNOW = 8, 9, 10, 11
|
|
24
|
+
# Rejected by default: no data, saturated/defective, cloud shadow, clouds (all), cirrus, snow/ice.
|
|
25
|
+
# Kept: dark area pixels (2), vegetation (4), not-vegetated (5), water (6), unclassified (7).
|
|
26
|
+
DEFAULT_INVALID_SCL = (0, 1, 3, 8, 9, 10, 11)
|
|
27
|
+
_CLOUDY = (3, 8, 9, 10)
|
|
28
|
+
|
|
29
|
+
Reader = Callable[[Scene, str, Grid], np.ndarray]
|
|
30
|
+
Progress = Callable[[str], None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def valid_from_scl(
|
|
34
|
+
scl: np.ndarray, res: float = 20.0, invalid: tuple[int, ...] = DEFAULT_INVALID_SCL, buffer_m: float = 30.0
|
|
35
|
+
) -> np.ndarray:
|
|
36
|
+
"""True where the pixel is usable. Cloud/shadow pixels are dilated by ``buffer_m`` metres."""
|
|
37
|
+
bad = np.isin(scl, invalid)
|
|
38
|
+
iters = int(round(buffer_m / res))
|
|
39
|
+
if iters > 0:
|
|
40
|
+
cloudy = np.isin(scl, _CLOUDY)
|
|
41
|
+
if cloudy.any():
|
|
42
|
+
bad |= binary_dilation(cloudy, iterations=iters)
|
|
43
|
+
return ~bad
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def to_reflectance(dn: np.ndarray, scale: float, offset: float) -> np.ndarray:
|
|
47
|
+
refl = dn.astype(np.float32) * np.float32(scale) + np.float32(offset)
|
|
48
|
+
np.clip(refl, 0.0, 2.0, out=refl)
|
|
49
|
+
return refl
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Composite:
|
|
54
|
+
bands: dict[str, np.ndarray] # float32 reflectance; NaN where no valid observation
|
|
55
|
+
count: np.ndarray # uint8, number of valid observations per pixel
|
|
56
|
+
scenes: list[Scene]
|
|
57
|
+
candidates: int
|
|
58
|
+
scene_valid_fraction: dict[str, float] = field(default_factory=dict)
|
|
59
|
+
# per-scene true-colour bands, subsampled by ``frame_step`` (float16, NaN = cloud / no data), for the report timeline
|
|
60
|
+
frames: dict[str, np.ndarray] = field(default_factory=dict)
|
|
61
|
+
frame_step: int = 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def strip_median(stack: np.ndarray, max_elems: int = 12_000_000) -> np.ndarray:
|
|
65
|
+
"""nanmedian over axis 0, in row strips to bound temporary memory."""
|
|
66
|
+
n, h, w = stack.shape
|
|
67
|
+
out = np.empty((h, w), dtype=np.float32)
|
|
68
|
+
rows = max(1, max_elems // max(1, n * w))
|
|
69
|
+
with warnings.catch_warnings():
|
|
70
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
71
|
+
for r0 in range(0, h, rows):
|
|
72
|
+
out[r0 : r0 + rows] = np.nanmedian(stack[:, r0 : r0 + rows], axis=0)
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_composite(
|
|
77
|
+
candidates: list[Scene],
|
|
78
|
+
grid: Grid,
|
|
79
|
+
mask: np.ndarray,
|
|
80
|
+
max_scenes: int = 8,
|
|
81
|
+
min_scene_fraction: float = 0.02,
|
|
82
|
+
reader: Reader = cog_reader,
|
|
83
|
+
workers: int = 6,
|
|
84
|
+
progress: Progress | None = None,
|
|
85
|
+
label: str = "",
|
|
86
|
+
bands: tuple[str, ...] = BANDS,
|
|
87
|
+
frame_max_dim: int = 0,
|
|
88
|
+
) -> Composite:
|
|
89
|
+
"""Select the scenes that really cover the AOI cloud-free, then median-composite them."""
|
|
90
|
+
say = progress or (lambda m: None)
|
|
91
|
+
h, w = grid.shape
|
|
92
|
+
n_aoi = max(1, int(mask.sum()))
|
|
93
|
+
|
|
94
|
+
# 1) read SCL of every candidate (cheap, 20 m) and rank by cloud-free AOI coverage
|
|
95
|
+
say(f"{label}: checking cloud cover of {len(candidates)} candidate scenes")
|
|
96
|
+
def read_scl(s: Scene):
|
|
97
|
+
try:
|
|
98
|
+
return reader(s, "scl", grid)
|
|
99
|
+
except Exception as exc: # unreadable scene: skip it instead of failing the whole run
|
|
100
|
+
say(f"{label}: skipping {s.id} ({str(exc)[:80]})")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
104
|
+
scls = list(ex.map(read_scl, candidates))
|
|
105
|
+
ranked = []
|
|
106
|
+
for s, scl in zip(candidates, scls):
|
|
107
|
+
if scl is None:
|
|
108
|
+
continue
|
|
109
|
+
valid = valid_from_scl(scl, grid.res) & mask
|
|
110
|
+
ranked.append((float(valid.sum()) / n_aoi, s, valid))
|
|
111
|
+
del scls
|
|
112
|
+
ranked.sort(key=lambda t: (-t[0], t[1].date))
|
|
113
|
+
chosen = [t for t in ranked if t[0] >= min_scene_fraction][:max_scenes]
|
|
114
|
+
chosen.sort(key=lambda t: t[1].date)
|
|
115
|
+
fractions = {t[1].id: round(t[0], 4) for t in ranked}
|
|
116
|
+
if not chosen:
|
|
117
|
+
empty = {b: np.full((h, w), np.nan, dtype=np.float32) for b in bands}
|
|
118
|
+
return Composite(empty, np.zeros((h, w), dtype=np.uint8), [], len(candidates), fractions)
|
|
119
|
+
|
|
120
|
+
scenes = [t[1] for t in chosen]
|
|
121
|
+
valid_stack = np.stack([t[2] for t in chosen]) # (n,h,w) bool
|
|
122
|
+
del ranked, chosen
|
|
123
|
+
gc.collect()
|
|
124
|
+
|
|
125
|
+
# 2) one band at a time: read -> reflectance -> mask -> strip-wise median
|
|
126
|
+
out: dict[str, np.ndarray] = {}
|
|
127
|
+
# small copies of each scene's red/green/blue (a few MB) let the report play the scenes one by one
|
|
128
|
+
step = max(1, -(-max(h, w) // frame_max_dim)) if frame_max_dim else 0
|
|
129
|
+
frames: dict[str, np.ndarray] = {}
|
|
130
|
+
for band in bands:
|
|
131
|
+
say(f"{label}: downloading + compositing band '{band}' from {len(scenes)} scenes")
|
|
132
|
+
stack = np.full((len(scenes), h, w), np.nan, dtype=np.float32)
|
|
133
|
+
|
|
134
|
+
def work(i: int, band=band, stack=stack):
|
|
135
|
+
s = scenes[i]
|
|
136
|
+
try:
|
|
137
|
+
dn = reader(s, band, grid)
|
|
138
|
+
except Exception:
|
|
139
|
+
if band in OPTIONAL_BANDS: # leave this scene out of the optional band, keep the run going
|
|
140
|
+
return
|
|
141
|
+
raise
|
|
142
|
+
scale, offset = s.radiometry.get(band, (1e-4, 0.0))
|
|
143
|
+
refl = to_reflectance(dn, scale, offset)
|
|
144
|
+
refl[(dn == 0) | ~valid_stack[i]] = np.nan
|
|
145
|
+
stack[i] = refl
|
|
146
|
+
|
|
147
|
+
with ThreadPoolExecutor(max_workers=min(workers, len(scenes))) as ex:
|
|
148
|
+
list(ex.map(work, range(len(scenes))))
|
|
149
|
+
if step and band in ("red", "green", "blue"):
|
|
150
|
+
frames[band] = stack[:, ::step, ::step].astype(np.float16)
|
|
151
|
+
out[band] = strip_median(stack)
|
|
152
|
+
del stack
|
|
153
|
+
gc.collect()
|
|
154
|
+
|
|
155
|
+
count = valid_stack.sum(axis=0).astype(np.uint8)
|
|
156
|
+
del valid_stack
|
|
157
|
+
# a pixel is only usable if all bands have a value
|
|
158
|
+
for b in [b for b in bands if b in OPTIONAL_BANDS]:
|
|
159
|
+
if not np.isfinite(out[b]).any(): # B12 unreadable everywhere: drop the band instead of the whole composite
|
|
160
|
+
del out[b]
|
|
161
|
+
required = [b for b in bands if b not in OPTIONAL_BANDS]
|
|
162
|
+
ok = np.ones((h, w), dtype=bool)
|
|
163
|
+
for b in required:
|
|
164
|
+
ok &= np.isfinite(out[b])
|
|
165
|
+
for b in out:
|
|
166
|
+
if b in required:
|
|
167
|
+
out[b][~ok] = np.nan
|
|
168
|
+
count[~ok] = 0
|
|
169
|
+
return Composite(out, count, scenes, len(candidates), fractions, frames, step or 1)
|
earthdiff/geocode.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Place-name search through OSM Nominatim, following the public usage policy
|
|
2
|
+
(https://operations.osmfoundation.org/policies/nominatim/): identifying User-Agent, at most one request per second,
|
|
3
|
+
cached results, no bulk use, attribution. Results are (c) OpenStreetMap contributors (ODbL)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .safety import NOMINATIM_HOST, clean_text
|
|
19
|
+
|
|
20
|
+
NOMINATIM_URL = f"https://{NOMINATIM_HOST}/search"
|
|
21
|
+
ATTRIBUTION = "Place search: (c) OpenStreetMap contributors (ODbL), via Nominatim"
|
|
22
|
+
REPO_URL = "https://github.com/mveyseldelibas/earthdiff"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GeocodeError(RuntimeError):
|
|
26
|
+
"""The place search failed (network, rate limit, bad response). The message is safe to show to the model."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Place:
|
|
31
|
+
name: str
|
|
32
|
+
display_name: str
|
|
33
|
+
kind: str # "class/type" as reported by OSM, e.g. "natural/water"
|
|
34
|
+
lon: float
|
|
35
|
+
lat: float
|
|
36
|
+
bbox: tuple[float, float, float, float] # west, south, east, north
|
|
37
|
+
importance: float
|
|
38
|
+
osm: str # "relation/123"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def user_agent(contact: str | None = None) -> str:
|
|
42
|
+
ua = f"earthdiff-mcp/{__version__} (+{REPO_URL})"
|
|
43
|
+
if contact:
|
|
44
|
+
ua += f" contact:{clean_text(contact, 80)}"
|
|
45
|
+
return ua
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _place_from(raw: dict[str, Any]) -> Place | None:
|
|
49
|
+
try:
|
|
50
|
+
south, north, west, east = (float(x) for x in raw["boundingbox"])
|
|
51
|
+
lon, lat = float(raw["lon"]), float(raw["lat"])
|
|
52
|
+
except (KeyError, TypeError, ValueError):
|
|
53
|
+
return None
|
|
54
|
+
vals = (south, north, west, east, lon, lat)
|
|
55
|
+
if not all(math.isfinite(v) for v in vals) or not (-90 <= south <= north <= 90 and -180 <= west <= east <= 180):
|
|
56
|
+
return None
|
|
57
|
+
display = clean_text(raw.get("display_name", ""), 240)
|
|
58
|
+
name = clean_text(raw.get("name") or display.split(",")[0], 120)
|
|
59
|
+
return Place(
|
|
60
|
+
name=name,
|
|
61
|
+
display_name=display,
|
|
62
|
+
kind=clean_text(f"{raw.get('category') or raw.get('class', '')}/{raw.get('type', '')}", 60),
|
|
63
|
+
lon=lon,
|
|
64
|
+
lat=lat,
|
|
65
|
+
bbox=(west, south, east, north),
|
|
66
|
+
importance=float(raw.get("importance") or 0.0),
|
|
67
|
+
osm=clean_text(f"{raw.get('osm_type', '')}/{raw.get('osm_id', '')}", 40),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Nominatim:
|
|
72
|
+
"""Rate-limited, caching Nominatim client. One instance per server process."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
contact: str | None = None,
|
|
77
|
+
cache_path: Path | None = None,
|
|
78
|
+
client: httpx.Client | None = None,
|
|
79
|
+
min_interval_s: float = 1.1,
|
|
80
|
+
max_per_hour: int = 60,
|
|
81
|
+
ttl_s: float = 30 * 86400,
|
|
82
|
+
timeout_s: float = 15.0,
|
|
83
|
+
clock: Callable[[], float] = time.monotonic,
|
|
84
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
85
|
+
now: Callable[[], float] = time.time,
|
|
86
|
+
):
|
|
87
|
+
self._client = client or httpx.Client(
|
|
88
|
+
headers={"User-Agent": user_agent(contact), "Accept": "application/json"},
|
|
89
|
+
timeout=timeout_s,
|
|
90
|
+
follow_redirects=False,
|
|
91
|
+
)
|
|
92
|
+
self._cache_path = cache_path
|
|
93
|
+
self._min_interval = min_interval_s
|
|
94
|
+
self._max_per_hour = max_per_hour
|
|
95
|
+
self._ttl = ttl_s
|
|
96
|
+
self._clock, self._sleep, self._now = clock, sleep, now
|
|
97
|
+
self._lock = threading.Lock()
|
|
98
|
+
self._last = -1e9
|
|
99
|
+
self._sent: list[float] = []
|
|
100
|
+
self._cache: dict[str, dict[str, Any]] = self._load()
|
|
101
|
+
|
|
102
|
+
# -- cache
|
|
103
|
+
def _load(self) -> dict[str, dict[str, Any]]:
|
|
104
|
+
if not self._cache_path:
|
|
105
|
+
return {}
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(Path(self._cache_path).read_text(encoding="utf-8"))
|
|
108
|
+
return data if isinstance(data, dict) else {}
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
def _save(self) -> None:
|
|
113
|
+
if not self._cache_path:
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
path = Path(self._cache_path)
|
|
117
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
if len(self._cache) > 500: # keep the newest entries only
|
|
119
|
+
keep = sorted(self._cache.items(), key=lambda kv: kv[1].get("t", 0), reverse=True)[:400]
|
|
120
|
+
self._cache = dict(keep)
|
|
121
|
+
tmp = path.with_suffix(".tmp")
|
|
122
|
+
tmp.write_text(json.dumps(self._cache, ensure_ascii=False), encoding="utf-8")
|
|
123
|
+
os.replace(tmp, path)
|
|
124
|
+
except OSError:
|
|
125
|
+
pass # a cache that cannot be written is not an error
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _key(q: str, countrycodes: str | None, language: str | None, limit: int) -> str:
|
|
129
|
+
return json.dumps([q.casefold(), countrycodes or "", (language or "").casefold(), limit], ensure_ascii=False)
|
|
130
|
+
|
|
131
|
+
# -- search
|
|
132
|
+
def search(self, q: str, countrycodes: str | None = None, language: str | None = None, limit: int = 5) -> tuple[list[Place], bool]:
|
|
133
|
+
"""Return ``(places, from_cache)``. Raises :class:`GeocodeError`."""
|
|
134
|
+
q = clean_text(q, 200)
|
|
135
|
+
if len(q) < 2:
|
|
136
|
+
raise GeocodeError("the place query is too short")
|
|
137
|
+
key = self._key(q, countrycodes, language, limit)
|
|
138
|
+
with self._lock:
|
|
139
|
+
hit = self._cache.get(key)
|
|
140
|
+
if hit and self._now() - float(hit.get("t", 0)) < self._ttl:
|
|
141
|
+
return [p for p in (_place_from(r) for r in hit["data"]) if p], True
|
|
142
|
+
|
|
143
|
+
t = self._clock()
|
|
144
|
+
self._sent = [x for x in self._sent if t - x < 3600]
|
|
145
|
+
if len(self._sent) >= self._max_per_hour:
|
|
146
|
+
raise GeocodeError(
|
|
147
|
+
f"place search limit reached ({self._max_per_hour} requests per hour per server, to respect the Nominatim usage policy); "
|
|
148
|
+
"give a bounding box instead or try again later"
|
|
149
|
+
)
|
|
150
|
+
wait = self._min_interval - (t - self._last)
|
|
151
|
+
if wait > 0:
|
|
152
|
+
self._sleep(wait) # <= 1 request per second, process wide
|
|
153
|
+
params: dict[str, Any] = {"q": q, "format": "jsonv2", "limit": limit, "addressdetails": 0, "dedupe": 1}
|
|
154
|
+
if countrycodes:
|
|
155
|
+
params["countrycodes"] = countrycodes
|
|
156
|
+
if language:
|
|
157
|
+
params["accept-language"] = language
|
|
158
|
+
self._last = self._clock()
|
|
159
|
+
self._sent.append(self._last)
|
|
160
|
+
try:
|
|
161
|
+
resp = self._client.get(NOMINATIM_URL, params=params)
|
|
162
|
+
except httpx.HTTPError as exc:
|
|
163
|
+
raise GeocodeError(f"place search failed ({type(exc).__name__}); try again or give a bounding box") from None
|
|
164
|
+
if resp.status_code in (403, 429, 503):
|
|
165
|
+
raise GeocodeError(
|
|
166
|
+
f"the Nominatim service refused or throttled the request (HTTP {resp.status_code}); wait a minute or give a bounding box"
|
|
167
|
+
)
|
|
168
|
+
if resp.status_code != 200:
|
|
169
|
+
raise GeocodeError(f"the Nominatim service answered HTTP {resp.status_code}")
|
|
170
|
+
try:
|
|
171
|
+
data = resp.json()
|
|
172
|
+
except ValueError:
|
|
173
|
+
raise GeocodeError("the Nominatim service returned an unreadable answer") from None
|
|
174
|
+
if not isinstance(data, list):
|
|
175
|
+
raise GeocodeError("the Nominatim service returned an unexpected answer")
|
|
176
|
+
slim = [
|
|
177
|
+
{k: r.get(k) for k in ("name", "display_name", "category", "class", "type", "lon", "lat", "boundingbox", "importance", "osm_type", "osm_id")}
|
|
178
|
+
for r in data[:limit]
|
|
179
|
+
if isinstance(r, dict)
|
|
180
|
+
]
|
|
181
|
+
self._cache[key] = {"t": self._now(), "data": slim}
|
|
182
|
+
self._save()
|
|
183
|
+
return [p for p in (_place_from(r) for r in slim) if p], False
|
|
184
|
+
|
|
185
|
+
def close(self) -> None:
|
|
186
|
+
self._client.close()
|
earthdiff/history.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""When did a vegetation loss happen, and what was it? Using the in-between years.
|
|
2
|
+
|
|
3
|
+
Comparing only the two ends hides events that happened in between and have partly recovered since: three years after a
|
|
4
|
+
wildfire the burn scar is green again in places, so the before/after dNBR is weak and the loss looks "unclear". When the
|
|
5
|
+
windows are years apart earthdiff already reads one pass per in-between year (``yearly.py``); here each vegetation-loss
|
|
6
|
+
polygon gets a short series of NBR, NDVI and visible brightness (before, each in-between year, after), the largest
|
|
7
|
+
one-step NBR drop is taken as the event, and the cause hint is judged on that step (same rules as ``cause.classify_cause``).
|
|
8
|
+
|
|
9
|
+
The result is stored on each polygon: ``event_after`` (date of the first pass that shows the loss), ``event_before``
|
|
10
|
+
(the pass just before), ``event_dnbr`` (NBR drop of that step) and, when the step says so, a new ``likely_cause``.
|
|
11
|
+
It is a hint like the rest: one pass per year, at the coarse frame resolution."""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from .cause import LABELS, _bincount_mean, _summary, classify_cause
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def composite_point(kind: str, date: str, comp, step: int) -> dict[str, Any] | None:
|
|
22
|
+
"""A series point from a period composite, subsampled to the frame grid."""
|
|
23
|
+
b = comp.bands
|
|
24
|
+
if "swir22" not in b or "nir" not in b:
|
|
25
|
+
return None
|
|
26
|
+
return point(kind, date, {k: b[k][::step, ::step] for k in ("red", "green", "blue", "nir", "swir22")})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def point(kind: str, date: str, bands: dict[str, np.ndarray]) -> dict[str, Any]:
|
|
30
|
+
nir, sw2, red = bands["nir"], bands["swir22"], bands["red"]
|
|
31
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
32
|
+
nbr = (nir - sw2) / (nir + sw2)
|
|
33
|
+
ndvi = (nir - red) / (nir + red)
|
|
34
|
+
vis = (bands["red"] + bands["green"] + bands["blue"]) / 3.0
|
|
35
|
+
return {"kind": kind, "date": date, "nbr": nbr.astype(np.float32), "ndvi": ndvi.astype(np.float32), "vis": vis.astype(np.float32)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def refine_with_history(result, series: list[dict[str, Any]], step: int) -> dict[str, Any] | None:
|
|
39
|
+
"""Re-judge vegetation-loss polygons on their largest one-step NBR drop. Changes features in place; returns new cause totals."""
|
|
40
|
+
series = [s for s in series if s is not None]
|
|
41
|
+
if len(series) < 3 or not any(s["kind"] == "between" for s in series):
|
|
42
|
+
return None
|
|
43
|
+
labels = result.labels[::step, ::step]
|
|
44
|
+
h, w = labels.shape
|
|
45
|
+
n = int(result.labels.max())
|
|
46
|
+
if n == 0:
|
|
47
|
+
return None
|
|
48
|
+
flat = labels.ravel()
|
|
49
|
+
sel = flat > 0
|
|
50
|
+
lab = flat[sel]
|
|
51
|
+
means = []
|
|
52
|
+
for s in series:
|
|
53
|
+
means.append({k: _bincount_mean(lab, s[k][:h, :w].ravel()[sel], n) for k in ("nbr", "ndvi", "vis")})
|
|
54
|
+
changed = 0
|
|
55
|
+
totals: dict[str, dict[str, float]] = {k: {"polygons": 0, "area_ha": 0.0} for k in LABELS}
|
|
56
|
+
for f, lid in zip(result.features, result.label_ids):
|
|
57
|
+
p = f["properties"]
|
|
58
|
+
if p["class"] != "vegetation_loss":
|
|
59
|
+
continue
|
|
60
|
+
nbr = [float(m["nbr"][lid]) for m in means]
|
|
61
|
+
ok = [i for i, v in enumerate(nbr) if np.isfinite(v)]
|
|
62
|
+
if len(ok) >= 3:
|
|
63
|
+
steps = [(nbr[a] - nbr[b], a, b) for a, b in zip(ok, ok[1:])]
|
|
64
|
+
drop, a, b = max(steps)
|
|
65
|
+
if drop > 0.05:
|
|
66
|
+
p["event_before"], p["event_after"] = series[a]["date"], series[b]["date"]
|
|
67
|
+
p["event_dnbr"] = round(drop, 3)
|
|
68
|
+
end = nbr[ok[0]] - nbr[ok[-1]]
|
|
69
|
+
# only re-judge when the step is clearly larger than the end-to-end difference (the scar has faded since)
|
|
70
|
+
if drop >= max(end + 0.05, 1.25 * end):
|
|
71
|
+
shape = result.shape_stats.get(lid, {})
|
|
72
|
+
m = {"dnbr": drop, "vis_after": float(means[b]["vis"][lid]), "vis_delta": float(means[b]["vis"][lid] - means[a]["vis"][lid]),
|
|
73
|
+
"rectangularity": float(shape.get("rectangularity", p.get("rectangularity", 0.0))), "area_ha": float(p["area_ha"])}
|
|
74
|
+
label, basis = classify_cause(m)
|
|
75
|
+
if label != p.get("likely_cause"):
|
|
76
|
+
changed += 1
|
|
77
|
+
p["likely_cause"] = label
|
|
78
|
+
p["likely_cause_basis"] = f"largest one-step drop between {series[a]['date']} and {series[b]['date']}; " + "; ".join(basis)
|
|
79
|
+
p["likely_cause_from"] = "in-between years"
|
|
80
|
+
lc = p.get("likely_cause")
|
|
81
|
+
if lc in totals:
|
|
82
|
+
totals[lc]["polygons"] += 1
|
|
83
|
+
totals[lc]["area_ha"] = round(totals[lc]["area_ha"] + p["area_ha"], 2)
|
|
84
|
+
out = _summary(totals)
|
|
85
|
+
out["from_in_between_years"] = {"series_points": [s["date"] for s in series], "relabelled_polygons": changed}
|
|
86
|
+
return out
|