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/periods.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Parsing of time windows such as ``2023-06`` or ``2023-06-01/2023-07-15``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import calendar
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date, timedelta
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Period:
|
|
12
|
+
start: date
|
|
13
|
+
end: date # inclusive
|
|
14
|
+
|
|
15
|
+
def stac(self) -> str:
|
|
16
|
+
return f"{self.start.isoformat()}T00:00:00Z/{self.end.isoformat()}T23:59:59Z"
|
|
17
|
+
|
|
18
|
+
def label(self) -> str:
|
|
19
|
+
if self.start.day == 1 and self.end == _month_end(self.end.year, self.end.month):
|
|
20
|
+
if (self.start.year, self.start.month) == (self.end.year, self.end.month):
|
|
21
|
+
return f"{self.start.year}-{self.start.month:02d}"
|
|
22
|
+
return f"{self.start.year}-{self.start.month:02d} .. {self.end.year}-{self.end.month:02d}"
|
|
23
|
+
return f"{self.start.isoformat()} .. {self.end.isoformat()}"
|
|
24
|
+
|
|
25
|
+
def midpoint(self) -> date:
|
|
26
|
+
return self.start + (self.end - self.start) / 2
|
|
27
|
+
|
|
28
|
+
def shift_years(self, years: int) -> "Period":
|
|
29
|
+
return Period(_shift(self.start, years), _shift(self.end, years))
|
|
30
|
+
|
|
31
|
+
def days(self) -> int:
|
|
32
|
+
return (self.end - self.start).days + 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _shift(d: date, years: int) -> date:
|
|
36
|
+
try:
|
|
37
|
+
return d.replace(year=d.year + years)
|
|
38
|
+
except ValueError: # 29 Feb
|
|
39
|
+
return d.replace(year=d.year + years, day=28)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _month_end(year: int, month: int) -> date:
|
|
43
|
+
return date(year, month, calendar.monthrange(year, month)[1])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_MONTH = re.compile(r"^(\d{4})-(\d{2})$")
|
|
47
|
+
_DAY = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_edge(text: str, is_end: bool) -> date:
|
|
51
|
+
text = text.strip()
|
|
52
|
+
m = _MONTH.match(text)
|
|
53
|
+
if m:
|
|
54
|
+
y, mo = int(m.group(1)), int(m.group(2))
|
|
55
|
+
if not 1 <= mo <= 12:
|
|
56
|
+
raise ValueError(f"invalid month in {text!r}")
|
|
57
|
+
return _month_end(y, mo) if is_end else date(y, mo, 1)
|
|
58
|
+
m = _DAY.match(text)
|
|
59
|
+
if m:
|
|
60
|
+
return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
61
|
+
raise ValueError(f"cannot parse {text!r}; use YYYY-MM, YYYY-MM-DD or START/END")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_period(text: str) -> Period:
|
|
65
|
+
"""``2023-06`` | ``2023-06-10`` | ``2023-06/2023-07`` | ``2023-06-01/2023-07-15``."""
|
|
66
|
+
if "/" in text or ".." in text:
|
|
67
|
+
a, b = re.split(r"/|\.\.", text, maxsplit=1)
|
|
68
|
+
start, end = _parse_edge(a, False), _parse_edge(b, True)
|
|
69
|
+
else:
|
|
70
|
+
start, end = _parse_edge(text, False), _parse_edge(text, True)
|
|
71
|
+
if end < start:
|
|
72
|
+
raise ValueError(f"period end is before start: {text!r}")
|
|
73
|
+
return Period(start, end)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def previous_month(today: date) -> Period:
|
|
77
|
+
first = today.replace(day=1)
|
|
78
|
+
last_prev = first - timedelta(days=1)
|
|
79
|
+
return Period(last_prev.replace(day=1), last_prev)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def season_gap_days(a: Period, b: Period) -> int:
|
|
83
|
+
"""Circular distance (days) between the day-of-year of the two window midpoints."""
|
|
84
|
+
da = a.midpoint().timetuple().tm_yday
|
|
85
|
+
db = b.midpoint().timetuple().tm_yday
|
|
86
|
+
diff = abs(da - db) % 365
|
|
87
|
+
return min(diff, 365 - diff)
|
earthdiff/pipeline.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""End-to-end orchestration: AOI + two periods -> report folder."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import gc
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import rasterio
|
|
13
|
+
from shapely.geometry import mapping
|
|
14
|
+
from shapely.geometry.base import BaseGeometry
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .aoi import MAX_AREA_KM2, AOIError, aoi_mask, area_km2, choose_resolution, make_grid
|
|
18
|
+
from .change import CLASSES, detect_changes, thresholds_for
|
|
19
|
+
from .cause import label_causes
|
|
20
|
+
from .composite import BANDS, BANDS_WITH_B12, Composite, Reader, build_composite
|
|
21
|
+
from .seasonal import seasonal_check
|
|
22
|
+
from .periods import Period, season_gap_days
|
|
23
|
+
from .reader import cog_reader
|
|
24
|
+
from .render import change_map_image, class_rgba, montage, png_bytes, save_png, to_mercator, true_color, webp_bytes
|
|
25
|
+
from .report import data_url, render_report
|
|
26
|
+
from .stac import BACKENDS, STACError, search_scenes
|
|
27
|
+
|
|
28
|
+
Progress = Callable[[str], None]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InsufficientDataError(RuntimeError):
|
|
32
|
+
"""Raised when too few cloud-free observations exist to make a meaningful comparison."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RunOptions:
|
|
37
|
+
name: str = "Area of interest"
|
|
38
|
+
backend: str = "earth-search"
|
|
39
|
+
resolution: float | None = None # metres; None = auto (10/20/30/60 m, <= 4 M pixels)
|
|
40
|
+
max_scenes: int = 8
|
|
41
|
+
max_cloud: float = 80.0
|
|
42
|
+
sensitivity: str = "default"
|
|
43
|
+
threshold_overrides: dict[str, float] = field(default_factory=dict)
|
|
44
|
+
mmu_ha: float = 0.5
|
|
45
|
+
max_area_km2: float = MAX_AREA_KM2
|
|
46
|
+
warn_valid_fraction: float = 0.60
|
|
47
|
+
min_valid_fraction: float = 0.20 # below this we refuse unless force=True
|
|
48
|
+
force: bool = False
|
|
49
|
+
allow_season_mismatch: bool = False # run even when the windows are in different seasons (CLI --any-season)
|
|
50
|
+
likely_cause: bool = True # label vegetation-loss polygons fire-like / clearing-like / unclear (reads B12 as well)
|
|
51
|
+
terrain: bool = True # read a small Copernicus DEM window for the report's offline 3D view and relief basemap
|
|
52
|
+
yearly_frames: bool = True # windows years apart: one small timeline frame per in-between year (same season; for viewing only)
|
|
53
|
+
seasonal_check: bool = False # compare with the same windows of earlier years to flag recurring (harvest-like) loss
|
|
54
|
+
seasonal_years: int = 2
|
|
55
|
+
warn_bright_fraction: float = 0.20 # warn when this share of the comparable area is very bright (salt, sand, snow)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class RunResult:
|
|
60
|
+
out_dir: Path
|
|
61
|
+
summary: dict[str, Any]
|
|
62
|
+
files: dict[str, Path]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
MAX_WINDOW_DAYS = 366 # one time window may span at most a year: a longer window mixes seasons in one median (same limit in the CLI, the web UI and the MCP server)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
SEASON_GAP_MAX = 60 # days apart in the yearly cycle above which a run is refused: crops, snow, sun angle and leaves differ too much between seasons
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_windows(before: Period, after: Period, allow_season_mismatch: bool = False) -> None:
|
|
72
|
+
for label, per in (("before", before), ("after", after)):
|
|
73
|
+
if per.days() > MAX_WINDOW_DAYS:
|
|
74
|
+
raise ValueError(f"the {label} window is {per.days()} days long; the limit is {MAX_WINDOW_DAYS} days. "
|
|
75
|
+
"Use a shorter window (one to three months of the same season works best); the two windows themselves may be years apart")
|
|
76
|
+
gap = season_gap_days(before, after)
|
|
77
|
+
if gap > SEASON_GAP_MAX and not allow_season_mismatch:
|
|
78
|
+
raise ValueError(f"the before and after windows are {gap} days apart in the seasonal cycle (limit {SEASON_GAP_MAX}): winter and summer differ in snow, "
|
|
79
|
+
"leaves, crops and shadows, so almost everything would look changed. Compare the same months of different years "
|
|
80
|
+
"(e.g. August 2020 and August 2024); the CLI can override this with --any-season")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def plan(geom: BaseGeometry, before: Period, after: Period, opts: RunOptions, search=search_scenes) -> dict[str, Any]:
|
|
84
|
+
"""Cheap pre-flight (STAC search only, nothing downloaded)."""
|
|
85
|
+
check_windows(before, after, opts.allow_season_mismatch)
|
|
86
|
+
km2 = area_km2(geom)
|
|
87
|
+
if km2 > opts.max_area_km2:
|
|
88
|
+
raise AOIError(f"AOI is {km2:,.0f} km2; the limit is {opts.max_area_km2:,.0f} km2 (raise it with --max-area-km2 if you have the RAM)")
|
|
89
|
+
res = opts.resolution or choose_resolution(geom)
|
|
90
|
+
sb = search(opts.backend, geom, before, opts.max_cloud)
|
|
91
|
+
sa = search(opts.backend, geom, after, opts.max_cloud)
|
|
92
|
+
return {
|
|
93
|
+
"aoi_km2": round(km2, 2),
|
|
94
|
+
"resolution_m": res,
|
|
95
|
+
"before_candidates": len(sb),
|
|
96
|
+
"after_candidates": len(sa),
|
|
97
|
+
"before_dates": sorted({s.date for s in sb}),
|
|
98
|
+
"after_dates": sorted({s.date for s in sa}),
|
|
99
|
+
"before_cloud": [round(s.cloud_cover, 1) for s in sb],
|
|
100
|
+
"after_cloud": [round(s.cloud_cover, 1) for s in sa],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_analysis(
|
|
105
|
+
geom: BaseGeometry,
|
|
106
|
+
before: Period,
|
|
107
|
+
after: Period,
|
|
108
|
+
out_dir: str | Path,
|
|
109
|
+
opts: RunOptions | None = None,
|
|
110
|
+
progress: Progress | None = None,
|
|
111
|
+
reader: Reader = cog_reader,
|
|
112
|
+
search=search_scenes,
|
|
113
|
+
dem_reader=None,
|
|
114
|
+
) -> RunResult:
|
|
115
|
+
opts = opts or RunOptions()
|
|
116
|
+
# terrain is read with the real network reader only when the imagery is too (tests that inject a fake reader get none)
|
|
117
|
+
if dem_reader is None and reader is cog_reader and opts.terrain:
|
|
118
|
+
from .terrain import read_dem_window
|
|
119
|
+
|
|
120
|
+
dem_reader = read_dem_window
|
|
121
|
+
say = progress or (lambda m: None)
|
|
122
|
+
out = Path(out_dir)
|
|
123
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
if opts.backend not in BACKENDS:
|
|
125
|
+
raise STACError(f"unknown backend {opts.backend!r}")
|
|
126
|
+
|
|
127
|
+
check_windows(before, after, opts.allow_season_mismatch)
|
|
128
|
+
km2 = area_km2(geom)
|
|
129
|
+
if km2 > opts.max_area_km2:
|
|
130
|
+
raise AOIError(f"AOI is {km2:,.0f} km2; the limit is {opts.max_area_km2:,.0f} km2 (raise it with --max-area-km2 if you have the RAM)")
|
|
131
|
+
res = opts.resolution or choose_resolution(geom)
|
|
132
|
+
grid = make_grid(geom, res)
|
|
133
|
+
mask = aoi_mask(geom, grid)
|
|
134
|
+
say(f"AOI {km2:,.1f} km2 -> grid {grid.width} x {grid.height} px at {res:g} m ({grid.epsg})")
|
|
135
|
+
|
|
136
|
+
say(f"searching Sentinel-2 L2A scenes ({opts.backend}) ...")
|
|
137
|
+
cand_b = search(opts.backend, geom, before, opts.max_cloud)
|
|
138
|
+
cand_a = search(opts.backend, geom, after, opts.max_cloud)
|
|
139
|
+
if not cand_b or not cand_a:
|
|
140
|
+
missing = "before" if not cand_b else "after"
|
|
141
|
+
raise InsufficientDataError(
|
|
142
|
+
f"no Sentinel-2 scenes with < {opts.max_cloud:g}% cloud found for the {missing} period. "
|
|
143
|
+
"Use a longer window, a different month, or raise --max-cloud."
|
|
144
|
+
)
|
|
145
|
+
say(f"found {len(cand_b)} candidate scenes (before) and {len(cand_a)} (after)")
|
|
146
|
+
|
|
147
|
+
bands = BANDS_WITH_B12 if opts.likely_cause else BANDS
|
|
148
|
+
comp_b = build_composite(cand_b, grid, mask, opts.max_scenes, reader=reader, progress=say, label="before", bands=bands, frame_max_dim=FRAME_MAX_DIM)
|
|
149
|
+
comp_a = build_composite(cand_a, grid, mask, opts.max_scenes, reader=reader, progress=say, label="after", bands=bands, frame_max_dim=FRAME_MAX_DIM)
|
|
150
|
+
between = {"frames": [], "series": []}
|
|
151
|
+
if (opts.yearly_frames or opts.likely_cause) and comp_a.frames:
|
|
152
|
+
from .yearly import between_frames
|
|
153
|
+
|
|
154
|
+
between = between_frames(geom, grid, mask, before, after, opts.backend, opts.max_cloud, comp_a.frame_step, search, reader, say,
|
|
155
|
+
with_nbr=opts.likely_cause)
|
|
156
|
+
n_aoi = max(1, int(mask.sum()))
|
|
157
|
+
valid_fraction = float((mask & (comp_b.count > 0) & (comp_a.count > 0)).sum()) / n_aoi
|
|
158
|
+
if valid_fraction < opts.min_valid_fraction and not opts.force:
|
|
159
|
+
raise InsufficientDataError(
|
|
160
|
+
f"only {valid_fraction * 100:.0f}% of the AOI is cloud-free in both periods (minimum {opts.min_valid_fraction * 100:.0f}%). "
|
|
161
|
+
"Widen the date windows, use other months, or pass --force to run anyway."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
say("detecting changes ...")
|
|
165
|
+
thr = thresholds_for(opts.sensitivity, **opts.threshold_overrides)
|
|
166
|
+
result = detect_changes(comp_b, comp_a, grid, mask, thr, opts.mmu_ha)
|
|
167
|
+
|
|
168
|
+
extra: dict[str, Any] = {"cause": None, "seasonal": None}
|
|
169
|
+
cause_note = None
|
|
170
|
+
if opts.likely_cause:
|
|
171
|
+
if "swir22" in comp_b.bands and "swir22" in comp_a.bands:
|
|
172
|
+
extra["cause"] = label_causes(result, comp_b.bands, comp_a.bands)
|
|
173
|
+
if extra["cause"] is not None and between["series"]:
|
|
174
|
+
# windows years apart: judge each loss on its largest one-year step, so an old, partly regrown burn is still recognised
|
|
175
|
+
from .history import composite_point, refine_with_history
|
|
176
|
+
|
|
177
|
+
k = comp_a.frame_step
|
|
178
|
+
ser = [composite_point("before", before.label(), comp_b, k)] + between["series"] + [composite_point("after", after.label(), comp_a, k)]
|
|
179
|
+
refined = refine_with_history(result, ser, k)
|
|
180
|
+
if refined:
|
|
181
|
+
extra["cause"] = refined
|
|
182
|
+
else:
|
|
183
|
+
cause_note = "likely_cause is unavailable: Sentinel-2 band B12 (SWIR2) could not be read for these scenes, so vegetation loss is not labelled fire-like / clearing-like."
|
|
184
|
+
if opts.seasonal_check:
|
|
185
|
+
say("seasonal check: comparing with earlier years ...")
|
|
186
|
+
extra["seasonal"] = seasonal_check(
|
|
187
|
+
result, grid, before, after, geom, opts.backend, opts.max_cloud, search, reader, years=opts.seasonal_years, progress=say
|
|
188
|
+
)
|
|
189
|
+
extra["bright_surface"] = {
|
|
190
|
+
"fraction_of_comparable_area": round(result.bright_fraction, 4),
|
|
191
|
+
"fraction_in_both_periods": round(result.bright_both_fraction, 4),
|
|
192
|
+
"warn_threshold": opts.warn_bright_fraction,
|
|
193
|
+
"reliable": result.bright_fraction < opts.warn_bright_fraction,
|
|
194
|
+
"rule": "mean(blue, green, red) >= 0.25 and NDVI < 0.15 in the before or after composite (salt, sand, snow, bare limestone, concrete)",
|
|
195
|
+
}
|
|
196
|
+
if result.bright_ndwi:
|
|
197
|
+
extra["bright_surface"]["water_like_share_of_bright_pixels"] = {
|
|
198
|
+
"index": "NDWI = (green - NIR) / (green + NIR) > 0 (exploratory; the default MNDWI rule scores dry salt as water too)",
|
|
199
|
+
"before": result.bright_ndwi["before"], "after": result.bright_ndwi["after"], "pixels": result.bright_ndwi["pixels"],
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
warnings = _warnings(opts, before, after, comp_b, comp_a, valid_fraction, result.totals, km2, result, extra, cause_note)
|
|
203
|
+
|
|
204
|
+
say("rendering outputs ...")
|
|
205
|
+
files = _write_outputs(out, geom, grid, mask, comp_b, comp_a, result, thr, opts, before, after, valid_fraction, warnings, km2, extra,
|
|
206
|
+
dem_reader=dem_reader if opts.terrain else None, say=say, between=between["frames"] if opts.yearly_frames else [])
|
|
207
|
+
summary = json.loads(files["summary"].read_text(encoding="utf-8"))
|
|
208
|
+
del comp_b, comp_a, result
|
|
209
|
+
gc.collect()
|
|
210
|
+
say(f"done -> {files['report']}")
|
|
211
|
+
return RunResult(out, summary, files)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# A changed share above LARGE_CHANGE_SHARE of the AOI usually means a seasonal/atmospheric mismatch, unless most of the change
|
|
215
|
+
# is fire-like vegetation loss: dNBR is an independent burn signal, so a large share then fits one big burn (e.g. inside a fire scar).
|
|
216
|
+
LARGE_CHANGE_SHARE = 0.30
|
|
217
|
+
MIN_FRAME_CLEAR = 0.3 # timeline frames must show at least this share of the area (cloudier passes are left out)
|
|
218
|
+
FRAME_MAX_DIM = 900 # longest side (pixels) of the per-scene frames played in the report timeline
|
|
219
|
+
FIRE_EXPLAINS_SHARE = 0.80
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def fire_like_share(totals: dict[str, Any], cause: dict[str, Any] | None) -> float:
|
|
223
|
+
"""Share of the total changed area that ``likely_cause`` labels fire-like (0 when the cause labels are missing)."""
|
|
224
|
+
changed = sum(float(t.get("area_ha", 0.0)) for t in (totals or {}).values())
|
|
225
|
+
fire = float((((cause or {}).get("totals") or {}).get("fire-like") or {}).get("area_ha", 0.0))
|
|
226
|
+
return fire / changed if changed > 0 else 0.0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _warnings(opts, before, after, cb: Composite, ca: Composite, valid_fraction, totals, km2, result=None, extra=None, cause_note=None) -> list[str]:
|
|
230
|
+
w: list[str] = period_warnings(before, after)
|
|
231
|
+
for label, c in (("before", cb), ("after", ca)):
|
|
232
|
+
if len(c.scenes) == 1:
|
|
233
|
+
w.append(f"Only one usable scene in the {label} period: the composite is a single acquisition, so residual cloud/haze or one-off conditions are not averaged out.")
|
|
234
|
+
elif not c.scenes:
|
|
235
|
+
w.append(f"No usable scene in the {label} period.")
|
|
236
|
+
if valid_fraction < opts.warn_valid_fraction:
|
|
237
|
+
w.append(f"Only {valid_fraction * 100:.0f}% of the AOI is cloud-free in both periods; the remainder is excluded and not reported as 'no change'.")
|
|
238
|
+
changed_ha = sum(t["area_ha"] for t in totals.values())
|
|
239
|
+
if km2 > 0 and changed_ha / (km2 * 100.0) > LARGE_CHANGE_SHARE:
|
|
240
|
+
fire = fire_like_share(totals, (extra or {}).get("cause"))
|
|
241
|
+
if fire >= FIRE_EXPLAINS_SHARE:
|
|
242
|
+
w.append(
|
|
243
|
+
f"Changes cover {changed_ha / (km2 * 100.0) * 100:.0f}% of the AOI, and {fire * 100:.0f}% of the changed area is fire-like vegetation loss "
|
|
244
|
+
"(dNBR, an independent burn signal): this is consistent with one large burn rather than a seasonal or atmospheric mismatch. "
|
|
245
|
+
"Inspect the before/after images to confirm."
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
w.append(
|
|
249
|
+
f"Changes cover {changed_ha / (km2 * 100.0) * 100:.0f}% of the AOI: such a large share often means a seasonal, atmospheric or "
|
|
250
|
+
"image-registration mismatch rather than real land-cover change. Inspect the before/after images."
|
|
251
|
+
)
|
|
252
|
+
if result is not None and result.bright_fraction >= opts.warn_bright_fraction:
|
|
253
|
+
w.append(
|
|
254
|
+
f"{result.bright_fraction * 100:.0f}% of the comparable area is very bright, non-vegetated surface (salt, sand, snow or similar; {result.bright_both_fraction * 100:.0f}% in both periods). "
|
|
255
|
+
"Change detection is NOT reliable on such surfaces: do not read the absence of change there as 'no change'. Confidence of polygons on bright pixels is lowered."
|
|
256
|
+
)
|
|
257
|
+
wl = ((extra or {}).get("bright_surface") or {}).get("water_like_share_of_bright_pixels")
|
|
258
|
+
if result is not None and wl and result.bright_fraction >= opts.warn_bright_fraction and abs(wl["after"] - wl["before"]) >= 0.25:
|
|
259
|
+
w.append(
|
|
260
|
+
f"Exploratory signal on the bright surface: the share of water-like pixels (NDWI > 0) changed from {wl['before'] * 100:.0f}% to {wl['after'] * 100:.0f}%, "
|
|
261
|
+
"which suggests a bright surface (a salt flat or shallow lake) became " + ("drier" if wl["after"] < wl["before"] else "wetter")
|
|
262
|
+
+ ". The default MNDWI rule cannot see this because dry salt also scores as water in MNDWI. Not a mapped change: verify with other data."
|
|
263
|
+
)
|
|
264
|
+
if cause_note:
|
|
265
|
+
w.append(cause_note)
|
|
266
|
+
sea = (extra or {}).get("seasonal")
|
|
267
|
+
if sea and sea.get("checked_polygons", 0) == 0 and sea.get("enabled"):
|
|
268
|
+
w.append("The seasonal check found no usable scenes in earlier years (windows before 2017 have no data), so no polygon was checked.")
|
|
269
|
+
elif sea:
|
|
270
|
+
ls = sea["likely_seasonal"]
|
|
271
|
+
if ls["polygons"] and ls["share_of_vegetation_loss"] >= 0.10:
|
|
272
|
+
w.append(
|
|
273
|
+
f"{ls['area_ha']:,.1f} ha ({ls['share_of_vegetation_loss'] * 100:.0f}% of the vegetation loss, {ls['polygons']} polygons) repeats at the same time of year in earlier years: "
|
|
274
|
+
"likely_seasonal (harvest or a seasonal cycle rather than a one-off loss). The polygons are kept and flagged."
|
|
275
|
+
)
|
|
276
|
+
return w
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def timeline_frames(grid, mask: np.ndarray, periods) -> dict[str, Any]:
|
|
281
|
+
"""One small true-colour image per scene, in date order, for the report timeline.
|
|
282
|
+
|
|
283
|
+
Only what the pass really saw is shown: cloud, cloud shadow, snow and missing pixels are left transparent (they are not filled
|
|
284
|
+
from other dates, which in winter made dark, misleading frames); ``clear_fraction`` says how much of the area is visible."""
|
|
285
|
+
from affine import Affine
|
|
286
|
+
|
|
287
|
+
from .aoi import Grid
|
|
288
|
+
|
|
289
|
+
frames: list[dict[str, Any]] = []
|
|
290
|
+
bounds = None
|
|
291
|
+
for label, comp in periods:
|
|
292
|
+
if not comp.frames or not comp.scenes:
|
|
293
|
+
continue
|
|
294
|
+
k = comp.frame_step
|
|
295
|
+
sub_mask = mask[::k, ::k]
|
|
296
|
+
hh, ww = sub_mask.shape
|
|
297
|
+
g = Grid(grid.epsg, grid.transform * Affine.scale(k), ww, hh, grid.res * k)
|
|
298
|
+
for i, sc in enumerate(comp.scenes):
|
|
299
|
+
bands = {}
|
|
300
|
+
own = np.ones((hh, ww), dtype=bool)
|
|
301
|
+
for b in ("red", "green", "blue"):
|
|
302
|
+
arr = comp.frames[b][i].astype(np.float32)
|
|
303
|
+
own &= np.isfinite(arr)
|
|
304
|
+
bands[b] = arr # NaN (not seen clear) stays transparent
|
|
305
|
+
rgba = true_color(bands, sub_mask)
|
|
306
|
+
img, bounds = to_mercator(rgba, g, max_dim=FRAME_MAX_DIM)
|
|
307
|
+
n = max(1, int(sub_mask.sum()))
|
|
308
|
+
frames.append({
|
|
309
|
+
"period": label, "date": sc.date[:10], "scene": sc.id,
|
|
310
|
+
"clear_fraction": round(float((own & sub_mask).sum()) / n, 3),
|
|
311
|
+
"img": data_url(webp_bytes(img, quality=80), "image/webp"),
|
|
312
|
+
})
|
|
313
|
+
# a pass that saw almost nothing (cloud) would show an empty frame: keep the ones showing at least MIN_FRAME_CLEAR of the area,
|
|
314
|
+
# and at least the clearest one of each period
|
|
315
|
+
keep = [f for f in frames if f["clear_fraction"] >= MIN_FRAME_CLEAR]
|
|
316
|
+
for label in {f["period"] for f in frames}:
|
|
317
|
+
if not any(f["period"] == label for f in keep):
|
|
318
|
+
keep.append(max((f for f in frames if f["period"] == label), key=lambda f: f["clear_fraction"]))
|
|
319
|
+
keep.sort(key=lambda f: f["date"])
|
|
320
|
+
return {"frames": keep, "bounds": bounds}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def period_warnings(before: Period, after: Period) -> list[str]:
|
|
324
|
+
"""Warnings that depend on the two time windows only (shared by ``run_analysis`` and the MCP ``plan_change``)."""
|
|
325
|
+
w: list[str] = []
|
|
326
|
+
gap = season_gap_days(before, after)
|
|
327
|
+
if gap > 45:
|
|
328
|
+
w.append(
|
|
329
|
+
f"The two windows are {gap} days apart in the seasonal cycle. Vegetation and water change naturally with the season: "
|
|
330
|
+
"compare the same time of year in different years to avoid false alarms."
|
|
331
|
+
)
|
|
332
|
+
if min(before.days(), after.days()) < 14:
|
|
333
|
+
w.append("A window shorter than two weeks usually contains very few cloud-free scenes; consider a longer window.")
|
|
334
|
+
return w
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _years(scenes_b, scenes_a) -> str:
|
|
338
|
+
years = sorted({s.datetime.year for s in list(scenes_b) + list(scenes_a)})
|
|
339
|
+
if not years:
|
|
340
|
+
return ""
|
|
341
|
+
return ", ".join(str(y) for y in years)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _write_outputs(out, geom, grid, mask, cb, ca, result, thr, opts, before, after, valid_fraction, warnings, km2, extra=None, dem_reader=None, say=None, between=None):
|
|
345
|
+
extra = extra or {}
|
|
346
|
+
files: dict[str, Path] = {}
|
|
347
|
+
fc = {
|
|
348
|
+
"type": "FeatureCollection",
|
|
349
|
+
"name": "earthdiff_changes",
|
|
350
|
+
"features": result.features,
|
|
351
|
+
"earthdiff": {
|
|
352
|
+
"version": __version__,
|
|
353
|
+
"before": before.label(),
|
|
354
|
+
"after": after.label(),
|
|
355
|
+
"note": "Heuristic spectral-index change polygons. Areas in hectares (UTM pixel counts). Screening product - verify before acting.",
|
|
356
|
+
},
|
|
357
|
+
}
|
|
358
|
+
files["geojson"] = out / "changes.geojson"
|
|
359
|
+
files["geojson"].write_text(json.dumps(fc, ensure_ascii=False), encoding="utf-8")
|
|
360
|
+
|
|
361
|
+
files["class_tif"] = out / "change_class.tif"
|
|
362
|
+
with rasterio.open(
|
|
363
|
+
files["class_tif"], "w", driver="GTiff", height=grid.height, width=grid.width, count=1, dtype="uint8",
|
|
364
|
+
crs=grid.crs, transform=grid.transform, nodata=0, compress="deflate",
|
|
365
|
+
) as dst:
|
|
366
|
+
dst.write(result.classmap, 1)
|
|
367
|
+
dst.update_tags(classes=json.dumps({str(c.id): c.key for c in CLASSES.values()}))
|
|
368
|
+
|
|
369
|
+
rgb_b = true_color(cb.bands, mask)
|
|
370
|
+
rgb_a = true_color(ca.bands, mask)
|
|
371
|
+
cls_rgba = class_rgba(result.classmap)
|
|
372
|
+
|
|
373
|
+
big = 2400
|
|
374
|
+
img_b, bounds = to_mercator(rgb_b, grid, max_dim=big)
|
|
375
|
+
img_a, _ = to_mercator(rgb_a, grid, max_dim=big)
|
|
376
|
+
img_c, _ = to_mercator(cls_rgba, grid, categorical=True, max_dim=big)
|
|
377
|
+
files["before"], files["after"] = out / "before.png", out / "after.png"
|
|
378
|
+
save_png(img_b, files["before"])
|
|
379
|
+
save_png(img_a, files["after"])
|
|
380
|
+
|
|
381
|
+
totals = result.totals
|
|
382
|
+
cm = change_map_image(rgb_a, result.classmap, totals)
|
|
383
|
+
files["change_map"] = out / "change_map.png"
|
|
384
|
+
cm.save(files["change_map"], optimize=True)
|
|
385
|
+
files["overview"] = out / "overview.png"
|
|
386
|
+
montage(rgb_b, rgb_a, cm, (f"Before {before.label()}", f"After {after.label()}", "Detected changes")).save(files["overview"], optimize=True)
|
|
387
|
+
|
|
388
|
+
web = 1600
|
|
389
|
+
wb, _ = to_mercator(rgb_b, grid, max_dim=web)
|
|
390
|
+
wa, _ = to_mercator(rgb_a, grid, max_dim=web)
|
|
391
|
+
wc, _ = to_mercator(cls_rgba, grid, categorical=True, max_dim=web)
|
|
392
|
+
images = {
|
|
393
|
+
"before": data_url(webp_bytes(wb), "image/webp"),
|
|
394
|
+
"after": data_url(webp_bytes(wa), "image/webp"),
|
|
395
|
+
"changes": data_url(png_bytes(wc), "image/png"),
|
|
396
|
+
}
|
|
397
|
+
timeline = timeline_frames(grid, mask, (("before", cb), ("after", ca)))
|
|
398
|
+
if between:
|
|
399
|
+
timeline["frames"] = sorted(timeline["frames"] + list(between), key=lambda f: f["date"])
|
|
400
|
+
terrain = None
|
|
401
|
+
if dem_reader is not None:
|
|
402
|
+
from .terrain import build_terrain
|
|
403
|
+
|
|
404
|
+
(say or (lambda m: None))("reading terrain (Copernicus DEM GLO-30) for the 3D view ...")
|
|
405
|
+
try:
|
|
406
|
+
terrain = build_terrain(bounds, dem_reader)
|
|
407
|
+
except Exception: # pragma: no cover - the 3D view is optional; never fail a run for it
|
|
408
|
+
terrain = None
|
|
409
|
+
|
|
410
|
+
scenes_meta = []
|
|
411
|
+
for period, comp, label in ((before, cb, "before"), (after, ca, "after")):
|
|
412
|
+
for s in comp.scenes:
|
|
413
|
+
scenes_meta.append(
|
|
414
|
+
{"period": f"{label} ({period.label()})", "id": s.id, "date": s.date, "cloud_cover": s.cloud_cover,
|
|
415
|
+
"valid_fraction": comp.scene_valid_fraction.get(s.id, 0.0)}
|
|
416
|
+
)
|
|
417
|
+
be = BACKENDS[opts.backend]
|
|
418
|
+
years = _years(cb.scenes, ca.scenes)
|
|
419
|
+
attribution = f"Contains modified Copernicus Sentinel data {years}".strip()
|
|
420
|
+
meta = {
|
|
421
|
+
"name": opts.name,
|
|
422
|
+
"version": __version__,
|
|
423
|
+
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
|
424
|
+
"before_label": before.label(),
|
|
425
|
+
"after_label": after.label(),
|
|
426
|
+
"aoi_km2": round(km2, 2),
|
|
427
|
+
"resolution_m": grid.res,
|
|
428
|
+
"valid_fraction": round(valid_fraction, 4),
|
|
429
|
+
"scenes_before": len(cb.scenes),
|
|
430
|
+
"scenes_after": len(ca.scenes),
|
|
431
|
+
"mmu_ha": opts.mmu_ha,
|
|
432
|
+
"sensitivity": opts.sensitivity,
|
|
433
|
+
"backend": opts.backend,
|
|
434
|
+
"backend_attribution": be.attribution,
|
|
435
|
+
}
|
|
436
|
+
from .xlsx import report_workbook
|
|
437
|
+
|
|
438
|
+
files["xlsx"] = out / "changes.xlsx"
|
|
439
|
+
files["xlsx"].write_bytes(report_workbook("en", meta, totals, warnings, fc, scenes_meta, extra, attribution, (terrain or {}).get("attribution")))
|
|
440
|
+
html_text = render_report(
|
|
441
|
+
title=f"earthdiff - {opts.name}",
|
|
442
|
+
meta=meta,
|
|
443
|
+
totals=totals,
|
|
444
|
+
warnings=warnings,
|
|
445
|
+
geojson=fc,
|
|
446
|
+
aoi_geojson={"type": "Feature", "properties": {}, "geometry": mapping(geom)},
|
|
447
|
+
bounds=bounds,
|
|
448
|
+
images=images,
|
|
449
|
+
scenes=scenes_meta,
|
|
450
|
+
thresholds=thr.as_dict(),
|
|
451
|
+
attribution=attribution,
|
|
452
|
+
extras=extra,
|
|
453
|
+
timeline=timeline,
|
|
454
|
+
terrain=terrain,
|
|
455
|
+
)
|
|
456
|
+
files["report"] = out / "report.html"
|
|
457
|
+
files["report"].write_text(html_text, encoding="utf-8")
|
|
458
|
+
|
|
459
|
+
summary = {
|
|
460
|
+
**meta,
|
|
461
|
+
"before_period": [before.start.isoformat(), before.end.isoformat()],
|
|
462
|
+
"after_period": [after.start.isoformat(), after.end.isoformat()],
|
|
463
|
+
"totals": totals,
|
|
464
|
+
"changed_fraction_of_aoi": round(sum(t["area_ha"] for t in totals.values()) / max(km2 * 100.0, 1e-9), 4),
|
|
465
|
+
"warnings": warnings,
|
|
466
|
+
"likely_cause": extra.get("cause"),
|
|
467
|
+
"seasonal_check": extra.get("seasonal"),
|
|
468
|
+
"bright_surface": extra.get("bright_surface"),
|
|
469
|
+
"scenes": scenes_meta,
|
|
470
|
+
"thresholds": thr.as_dict(),
|
|
471
|
+
"attribution": attribution,
|
|
472
|
+
"files": {k: v.name for k, v in files.items()},
|
|
473
|
+
}
|
|
474
|
+
files["summary"] = out / "summary.json"
|
|
475
|
+
files["summary"].write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
476
|
+
return files
|
earthdiff/reader.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Windowed reads of Sentinel-2 COGs onto the analysis grid (never whole tiles)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import rasterio
|
|
8
|
+
from rasterio.enums import Resampling
|
|
9
|
+
from rasterio.vrt import WarpedVRT
|
|
10
|
+
|
|
11
|
+
from .aoi import Grid
|
|
12
|
+
from .stac import Scene
|
|
13
|
+
|
|
14
|
+
GDAL_ENV = {
|
|
15
|
+
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
|
|
16
|
+
"CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif,.tiff,.TIF",
|
|
17
|
+
"GDAL_HTTP_MULTIPLEX": "YES",
|
|
18
|
+
"GDAL_HTTP_MAX_RETRY": "4",
|
|
19
|
+
"GDAL_HTTP_RETRY_DELAY": "1",
|
|
20
|
+
"GDAL_HTTP_TIMEOUT": "60",
|
|
21
|
+
"VSI_CACHE": "TRUE",
|
|
22
|
+
"GDAL_NUM_THREADS": "1",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_to_grid(href: str, grid: Grid, categorical: bool = False, retries: int = 3) -> np.ndarray:
|
|
27
|
+
"""Read one band of a COG, warped/resampled onto ``grid``. 0 is the nodata value."""
|
|
28
|
+
last: Exception | None = None
|
|
29
|
+
for attempt in range(retries):
|
|
30
|
+
try:
|
|
31
|
+
with rasterio.Env(**GDAL_ENV):
|
|
32
|
+
with rasterio.open(href) as src:
|
|
33
|
+
native = abs(src.transform.a)
|
|
34
|
+
if categorical:
|
|
35
|
+
rs = Resampling.nearest
|
|
36
|
+
elif grid.res > native * 1.01:
|
|
37
|
+
rs = Resampling.average
|
|
38
|
+
else:
|
|
39
|
+
rs = Resampling.bilinear
|
|
40
|
+
with WarpedVRT(
|
|
41
|
+
src,
|
|
42
|
+
crs=grid.crs,
|
|
43
|
+
transform=grid.transform,
|
|
44
|
+
width=grid.width,
|
|
45
|
+
height=grid.height,
|
|
46
|
+
resampling=rs,
|
|
47
|
+
src_nodata=0,
|
|
48
|
+
nodata=0,
|
|
49
|
+
) as vrt:
|
|
50
|
+
return vrt.read(1)
|
|
51
|
+
except Exception as exc: # pragma: no cover - network dependent
|
|
52
|
+
last = exc
|
|
53
|
+
time.sleep(1.5 * (attempt + 1))
|
|
54
|
+
raise RuntimeError(f"could not read {href}: {last}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cog_reader(scene: Scene, band: str, grid: Grid) -> np.ndarray:
|
|
58
|
+
"""Default reader: ``band`` is a logical name ('red', 'nir', 'scl', ...)."""
|
|
59
|
+
return read_to_grid(scene.hrefs[band], grid, categorical=(band == "scl"))
|