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/render.py ADDED
@@ -0,0 +1,146 @@
1
+ """PNG / WebP rendering: true colour, change overlays, Web-Mercator warping, montage."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ from PIL import Image, ImageDraw, ImageFont
9
+ from pyproj import Transformer
10
+ from rasterio.enums import Resampling
11
+ from rasterio.transform import array_bounds
12
+ from rasterio.warp import calculate_default_transform, reproject
13
+
14
+ from .aoi import Grid
15
+ from .change import CLASSES
16
+
17
+
18
+ def _hex(color: str) -> tuple[int, int, int]:
19
+ color = color.lstrip("#")
20
+ return int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
21
+
22
+
23
+ def true_color(bands: dict[str, np.ndarray], mask: np.ndarray | None = None, vmax: float = 0.20, gamma: float = 0.75) -> np.ndarray:
24
+ """Sentinel-2 true colour (B04,B03,B02) as RGBA uint8. NaN / outside-AOI pixels are transparent."""
25
+ rgb = np.stack([bands["red"], bands["green"], bands["blue"]], axis=-1)
26
+ finite = np.isfinite(rgb).all(axis=-1)
27
+ x = np.clip(np.nan_to_num(rgb, nan=0.0) / vmax, 0.0, 1.0) ** gamma
28
+ out = np.zeros(rgb.shape[:2] + (4,), dtype=np.uint8)
29
+ out[..., :3] = (x * 255).astype(np.uint8)
30
+ alpha = finite if mask is None else (finite & mask)
31
+ out[..., 3] = np.where(alpha, 255, 0).astype(np.uint8)
32
+ return out
33
+
34
+
35
+ def class_rgba(classmap: np.ndarray, alpha: int = 215) -> np.ndarray:
36
+ out = np.zeros(classmap.shape + (4,), dtype=np.uint8)
37
+ for cid, info in CLASSES.items():
38
+ m = classmap == cid
39
+ if m.any():
40
+ out[m, :3] = _hex(info.color)
41
+ out[m, 3] = alpha
42
+ return out
43
+
44
+
45
+ def to_mercator(rgba: np.ndarray, grid: Grid, categorical: bool = False, max_dim: int | None = None):
46
+ """Warp an RGBA raster on ``grid`` to EPSG:3857. Returns (rgba, [[south, west], [north, east]])."""
47
+ h, w = rgba.shape[:2]
48
+ dst_t, dw, dh = calculate_default_transform(grid.crs, "EPSG:3857", w, h, *grid.bounds)
49
+ dst = np.zeros((4, dh, dw), dtype=np.uint8)
50
+ rs = Resampling.nearest if categorical else Resampling.bilinear
51
+ for i in range(4):
52
+ reproject(
53
+ source=np.ascontiguousarray(rgba[..., i]),
54
+ destination=dst[i],
55
+ src_transform=grid.transform,
56
+ src_crs=grid.crs,
57
+ dst_transform=dst_t,
58
+ dst_crs="EPSG:3857",
59
+ resampling=rs,
60
+ )
61
+ out = np.moveaxis(dst, 0, -1)
62
+ west, south, east, north = array_bounds(dh, dw, dst_t)
63
+ t = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True)
64
+ w_lon, s_lat = t.transform(west, south)
65
+ e_lon, n_lat = t.transform(east, north)
66
+ if max_dim and max(out.shape[:2]) > max_dim:
67
+ im = Image.fromarray(out, "RGBA")
68
+ k = max_dim / max(out.shape[:2])
69
+ im = im.resize((max(1, round(out.shape[1] * k)), max(1, round(out.shape[0] * k))), Image.NEAREST if categorical else Image.LANCZOS)
70
+ out = np.asarray(im)
71
+ return np.ascontiguousarray(out), [[s_lat, w_lon], [n_lat, e_lon]]
72
+
73
+
74
+ def save_png(rgba: np.ndarray, path: str | Path) -> None:
75
+ Image.fromarray(rgba, "RGBA").save(path, optimize=True)
76
+
77
+
78
+ def webp_bytes(rgba: np.ndarray, quality: int = 86) -> bytes:
79
+ buf = io.BytesIO()
80
+ Image.fromarray(rgba, "RGBA").save(buf, "WEBP", quality=quality, method=4)
81
+ return buf.getvalue()
82
+
83
+
84
+ def png_bytes(rgba: np.ndarray) -> bytes:
85
+ buf = io.BytesIO()
86
+ Image.fromarray(rgba, "RGBA").save(buf, "PNG", optimize=True)
87
+ return buf.getvalue()
88
+
89
+
90
+ def _font(size: int):
91
+ try:
92
+ return ImageFont.load_default(size=size)
93
+ except TypeError: # very old Pillow
94
+ return ImageFont.load_default()
95
+
96
+
97
+ def _flatten(rgba: np.ndarray, bg=(235, 235, 235)) -> Image.Image:
98
+ base = Image.new("RGBA", (rgba.shape[1], rgba.shape[0]), bg + (255,))
99
+ base.alpha_composite(Image.fromarray(rgba, "RGBA"))
100
+ return base.convert("RGB")
101
+
102
+
103
+ def change_map_image(after_rgba: np.ndarray, classmap: np.ndarray, totals: dict, max_dim: int = 1400) -> Image.Image:
104
+ """After-image (faded) with change classes on top and a legend with hectares."""
105
+ base = Image.fromarray(after_rgba, "RGBA")
106
+ faded = np.asarray(base).copy()
107
+ faded[..., :3] = (faded[..., :3].astype(np.float32) * 0.55 + 255 * 0.45).astype(np.uint8)
108
+ img = _flatten(faded)
109
+ over = Image.fromarray(class_rgba(classmap, 235), "RGBA")
110
+ img = img.convert("RGBA")
111
+ img.alpha_composite(over)
112
+ img = img.convert("RGB")
113
+ k = min(1.0, max_dim / max(img.size))
114
+ if k < 1.0:
115
+ img = img.resize((round(img.width * k), round(img.height * k)), Image.NEAREST)
116
+ d = ImageDraw.Draw(img)
117
+ f = _font(max(13, img.width // 70))
118
+ rows = [(info, totals.get(info.key, {}).get("area_ha", 0.0)) for info in CLASSES.values()]
119
+ lh = f.size + 8
120
+ box_w = int(f.size * 20)
121
+ box_h = lh * len(rows) + 14
122
+ x0, y0 = 10, img.height - box_h - 10
123
+ d.rectangle([x0, y0, x0 + box_w, y0 + box_h], fill=(255, 255, 255), outline=(60, 60, 60))
124
+ for i, (info, ha) in enumerate(rows):
125
+ y = y0 + 8 + i * lh
126
+ d.rectangle([x0 + 8, y, x0 + 8 + f.size, y + f.size], fill=_hex(info.color), outline=(40, 40, 40))
127
+ d.text((x0 + 16 + f.size, y - 1), f"{info.label}: {ha:,.1f} ha", fill=(20, 20, 20), font=f)
128
+ return img
129
+
130
+
131
+ def montage(before_rgba: np.ndarray, after_rgba: np.ndarray, change_img: Image.Image, titles: tuple[str, str, str], panel: int = 620) -> Image.Image:
132
+ panels = [_flatten(before_rgba), _flatten(after_rgba), change_img.convert("RGB")]
133
+ resized = []
134
+ for p in panels:
135
+ k = panel / p.width
136
+ resized.append(p.resize((panel, max(1, round(p.height * k))), Image.LANCZOS))
137
+ hmax = max(p.height for p in resized)
138
+ cap = 36
139
+ sheet = Image.new("RGB", (panel * 3 + 8 * 4, hmax + cap + 16), (255, 255, 255))
140
+ d = ImageDraw.Draw(sheet)
141
+ f = _font(20)
142
+ for i, (p, t) in enumerate(zip(resized, titles)):
143
+ x = 8 + i * (panel + 8)
144
+ sheet.paste(p, (x, cap + 8))
145
+ d.text((x + 4, 8), t, fill=(20, 20, 20), font=f)
146
+ return sheet
earthdiff/report.py ADDED
@@ -0,0 +1,113 @@
1
+ """Self-contained HTML report (Leaflet inlined, imagery embedded as data URLs)."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import html
6
+ import json
7
+ from importlib import resources
8
+ from typing import Any
9
+
10
+ from .change import CLASSES
11
+ from .i18n import CLASS_LABELS_TR, basis_tr, method_html_tr, warning_tr
12
+ from .xlsx import report_workbook
13
+
14
+ _TEMPLATE_PKG = "earthdiff"
15
+
16
+
17
+ def _read(rel: str) -> str:
18
+ return resources.files(_TEMPLATE_PKG).joinpath(rel).read_text(encoding="utf-8")
19
+
20
+
21
+ def data_url(data: bytes, mime: str) -> str:
22
+ return f"data:{mime};base64," + base64.b64encode(data).decode("ascii")
23
+
24
+
25
+ def method_html(thresholds: dict[str, float], mmu_ha: float, resolution: float) -> str:
26
+ t = thresholds
27
+ rows = "".join(
28
+ f"<tr><td><span class='chip' style='background:{c.color}'></span>{html.escape(c.label)}</td><td><code>{html.escape(c.rule)}</code></td></tr>"
29
+ for c in CLASSES.values()
30
+ )
31
+ return f"""
32
+ <p>earthdiff compares two <b>median composites</b> of Sentinel-2 L2A surface reflectance. Cloud, cloud shadow, cirrus, snow and
33
+ saturated pixels are removed with the scene classification layer (SCL, cloud pixels buffered by 30&nbsp;m) before the per-pixel median is taken,
34
+ so each pixel is the median of its cloud-free observations in that period. Everything below is a plain, inspectable rule &ndash; no machine-learning model.</p>
35
+ <table><thead><tr><th>Change class</th><th>Rule (d = after &minus; before)</th></tr></thead><tbody>{rows}</tbody></table>
36
+ <pre>NDVI = (NIR - Red) / (NIR + Red) B08, B04
37
+ MNDWI = (Green - SWIR1) / (Green + SWIR1) B03, B11
38
+ BSI = ((SWIR1 + Red) - (NIR + Blue)) / ((SWIR1 + Red) + (NIR + Blue)) B11, B04, B08, B02</pre>
39
+ <p>Pixel rules are then cleaned: features narrower than 3 pixels are removed by a morphological opening (3x3) (for pixels &le; 20&nbsp;m), blobs smaller than the
40
+ <b>minimum mapping unit ({mmu_ha:g}&nbsp;ha)</b> are dropped, and the rest are polygonized (8-connectivity). Areas are pixel counts &times; pixel area
41
+ ({resolution:g}&nbsp;m pixels) on a UTM grid.</p>
42
+ <p><b>Confidence</b> is a heuristic score from 0 to 1 &mdash; <i>not</i> a probability: 0.5&times;magnitude (how far the mean index change exceeds the threshold, saturating at 2&times;)
43
+ + 0.3&times;valid observations (min of before/after, saturating at 3) + 0.2&times;size (log10 of area / MMU, saturating at 10&times;MMU). &ge;0.70 is &ldquo;high&rdquo;, &ge;0.45 &ldquo;medium&rdquo;.</p>
44
+ <h3>Likely cause, seasonal check, bright surfaces</h3>
45
+ <p><b>Likely cause</b> (vegetation-loss polygons only) is a hint, not a verdict: <code>fire-like</code> needs a dNBR of at least 0.27 (moderate-low burn severity of Key &amp; Benson 2006, the thresholds also used by Copernicus EFFIS)
46
+ plus a dark post-event surface and more evidence for fire (also an irregular outline) than for clearing; <code>clearing-like</code> means little burn signal or a straight-edged, bright outline (felling, harvest, ploughing, construction); everything else is <code>unclear</code>.
47
+ The brightness and shape thresholds are this project's own heuristics. When the two windows are years apart, each vegetation-loss polygon is also followed through one pass per in-between year; if one year-to-year step drops NBR much more than the whole period does (an event that has partly recovered since, such as an old burn), the hint is judged on that step and the report says when it happened. <b>Likely seasonal</b> (optional check) flags polygons whose NDVI fell by a similar amount in the same windows of earlier years; polygons are never removed.
48
+ <b>Bright surfaces</b> (salt, sand, snow: mean visible reflectance &ge; 0.25 and NDVI &lt; 0.15) saturate the imagery; when they cover a large share of the area the report warns that change there is not reliable and lowers the confidence of polygons on them.</p>
49
+ <h3>Limitations &mdash; please read</h3>
50
+ <ul>
51
+ <li><b>Seasonality and agriculture.</b> Crops, grassland and deciduous forest change colour with the seasons and with management. Compare the same season in different years; harvest, ploughing, irrigation and grazing look like vegetation loss/gain.</li>
52
+ <li><b>Not a burn-severity or forest-law product.</b> &ldquo;Vegetation loss&rdquo; says vegetation greenness fell; it does not say why (fire, felling, drought, harvest, construction). &ldquo;New bare/built-up&rdquo; can also be soil moisture, dried crops or exposed lake bed.</li>
53
+ <li><b>Water.</b> Shallow, turbid or algae-covered water and wet soil can fall on either side of the MNDWI rule; cloud shadow and terrain shadow can mimic water.</li>
54
+ <li><b>Resolution.</b> Sentinel-2 pixels are {resolution:g}&nbsp;m here (SWIR is natively 20&nbsp;m). Features narrower than a few pixels (roads, small buildings, narrow streams) are not reliably detected. The MMU hides everything smaller than {mmu_ha:g}&nbsp;ha.</li>
55
+ <li><b>Atmosphere and geometry.</b> Residual haze, sun/view-angle differences between dates and scene co-registration (~10&nbsp;m) create false alarms along edges.</li>
56
+ <li><b>No ground truth.</b> Results are screening hints. Verify anything consequential with higher-resolution imagery, field data or official sources.</li>
57
+ </ul>"""
58
+
59
+
60
+ def render_report(
61
+ *,
62
+ title: str,
63
+ meta: dict[str, Any],
64
+ totals: dict[str, Any],
65
+ warnings: list[str],
66
+ geojson: dict[str, Any],
67
+ aoi_geojson: dict[str, Any],
68
+ bounds: list[list[float]],
69
+ images: dict[str, str],
70
+ scenes: list[dict[str, Any]],
71
+ thresholds: dict[str, float],
72
+ attribution: str,
73
+ extras: dict[str, Any] | None = None,
74
+ timeline: dict[str, Any] | None = None,
75
+ terrain: dict[str, Any] | None = None,
76
+ ) -> str:
77
+ data = {
78
+ "meta": meta,
79
+ "totals": totals,
80
+ "warnings": warnings,
81
+ "geojson": geojson,
82
+ "aoi": aoi_geojson,
83
+ "bounds": bounds,
84
+ "img": images,
85
+ "scenes": scenes,
86
+ "thresholds": thresholds,
87
+ "attribution": attribution,
88
+ "extras": extras or {},
89
+ "timeline": timeline or {"frames": [], "bounds": None},
90
+ "terrain": terrain,
91
+ "classes": {c.key: {"label": c.label, "color": c.color} for c in CLASSES.values()},
92
+ "method_html": method_html(thresholds, meta["mmu_ha"], meta["resolution_m"]),
93
+ # Turkish texts; the page switches language in the browser (English stays the default for non-Turkish browsers)
94
+ "i18n": {"tr": {
95
+ "warnings": [warning_tr(w) for w in warnings],
96
+ "method_html": method_html_tr(CLASSES.values(), meta["mmu_ha"], meta["resolution_m"]),
97
+ "classes": CLASS_LABELS_TR,
98
+ }},
99
+ }
100
+ # the report's Excel button downloads one of these, in the language shown
101
+ data["xlsx"] = {lg: base64.b64encode(report_workbook(lg, meta, totals, warnings if lg == "en" else data["i18n"]["tr"]["warnings"], geojson, scenes, extras,
102
+ attribution, (terrain or {}).get("attribution"))).decode("ascii") for lg in ("en", "tr")}
103
+ for f in geojson.get("features", []):
104
+ p = f.get("properties", {})
105
+ if p.get("likely_cause_basis"):
106
+ p["likely_cause_basis_tr"] = basis_tr(p["likely_cause_basis"])
107
+ payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c").replace("\u2028", "\\u2028")
108
+ page = _read("templates/report.html")
109
+ # placeholders are replaced with plain str.replace so the big blobs are never re-scanned by a regex
110
+ page = page.replace("{{TITLE}}", html.escape(title))
111
+ page = page.replace("{{LEAFLET_CSS}}", _read("vendor/leaflet.css"))
112
+ page = page.replace("{{LEAFLET_JS}}", _read("vendor/leaflet.js").replace("</script", "<\\/script"))
113
+ return page.replace("{{DATA_JSON}}", payload)
earthdiff/safety.py ADDED
@@ -0,0 +1,93 @@
1
+ """Input hygiene, output-folder confinement, error redaction and the network allow-list of the MCP server.
2
+
3
+ Everything a model (or an OSM / STAC response) can put into a file name, a folder name or a message goes through here.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ import unicodedata
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from urllib.parse import urlparse
12
+
13
+ # C0/C1 control characters, zero-width and bidi override characters, line/paragraph separators
14
+ _CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]")
15
+ _TURKISH = str.maketrans({"ı": "i", "İ": "I", "ş": "s", "Ş": "S", "ğ": "g", "Ğ": "G", "ç": "c", "Ç": "C", "ö": "o", "Ö": "O", "ü": "u", "Ü": "U"})
16
+
17
+ # Hosts the server may talk to: STAC APIs, Sentinel-2 COG storage and the Nominatim place search. Nothing else.
18
+ STAC_HOSTS = ("earth-search.aws.element84.com", "planetarycomputer.microsoft.com")
19
+ DATA_HOSTS = ("sentinel-cogs.s3.us-west-2.amazonaws.com", "*.blob.core.windows.net")
20
+ NOMINATIM_HOST = "nominatim.openstreetmap.org"
21
+
22
+
23
+ def clean_text(value: object, max_len: int = 200) -> str:
24
+ """Printable single-line text: control and bidi characters removed, whitespace collapsed, length capped."""
25
+ s = unicodedata.normalize("NFC", str(value))
26
+ s = _CONTROL.sub(" ", s)
27
+ s = re.sub(r"\s+", " ", s).strip()
28
+ return s[:max_len]
29
+
30
+
31
+ def safe_filename(stem: str, max_len: int = 48) -> str:
32
+ """ASCII slug that is safe as a file or folder name on Windows, macOS and Linux (no separators, no dots at the ends)."""
33
+ s = str(stem).translate(_TURKISH)
34
+ s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("ascii")
35
+ s = re.sub(r"[^A-Za-z0-9_-]+", "_", s).strip("_-")[:max_len].strip("_-")
36
+ return s or "area"
37
+
38
+
39
+ def run_folder(root: Path, name: str, now: datetime | None = None) -> Path:
40
+ """Create and return ``<root>/<YYYYmmdd-HHMMSS>_<slug>``; refuses anything that would resolve outside ``root``."""
41
+ root = Path(root)
42
+ root.mkdir(parents=True, exist_ok=True)
43
+ root_resolved = root.resolve()
44
+ stamp = (now or datetime.now()).strftime("%Y%m%d-%H%M%S")
45
+ base = f"{stamp}_{safe_filename(name)}"
46
+ for n in range(1, 100):
47
+ candidate = root_resolved / (base if n == 1 else f"{base}-{n}")
48
+ if candidate.parent != root_resolved: # defence in depth: the slug cannot contain separators
49
+ raise ValueError("invalid output folder name")
50
+ try:
51
+ candidate.mkdir()
52
+ except FileExistsError:
53
+ continue
54
+ return candidate
55
+ raise ValueError("could not create a unique output folder")
56
+
57
+
58
+ # ----------------------------------------------------------------------------------------------- redaction
59
+ _WIN_PATH = re.compile(r"(?<![A-Za-z0-9])[A-Za-z]:[\\/][^\s'\"<>|*?]*")
60
+ _UNC_PATH = re.compile(r"\\\\[^\s'\"<>|*?]+")
61
+ _POSIX_PATH = re.compile(r"(?<![\w:/.])/(?:[\w.\-~]+/)+[\w.\-~]*")
62
+ _MAX_ERROR = 600
63
+
64
+
65
+ def redact(text: object, extra: tuple[str, ...] = ()) -> str:
66
+ """Remove local file-system paths (and the given extra literals such as the home or output folder) from a message."""
67
+ s = str(text)
68
+ s = _UNC_PATH.sub("<path>", s)
69
+ s = _WIN_PATH.sub("<path>", s)
70
+ s = _POSIX_PATH.sub("<path>", s)
71
+ for lit in sorted({e for e in extra if e and len(e) >= 3}, key=len, reverse=True):
72
+ s = s.replace(lit, "<path>").replace(lit.replace("\\", "/"), "<path>")
73
+ s = _CONTROL.sub(" ", s)
74
+ return s[:_MAX_ERROR]
75
+
76
+
77
+ # ----------------------------------------------------------------------------------------------- network allow-list
78
+ def host_allowed(url: str, patterns: tuple[str, ...]) -> bool:
79
+ """True when ``url`` is https and its host matches an exact name or a ``*.suffix`` pattern."""
80
+ try:
81
+ parsed = urlparse(url)
82
+ host = (parsed.hostname or "").lower()
83
+ except ValueError:
84
+ return False
85
+ if parsed.scheme != "https" or not host:
86
+ return False
87
+ for pat in patterns:
88
+ if pat.startswith("*."):
89
+ if host.endswith(pat[1:]) and len(host) > len(pat) - 1:
90
+ return True
91
+ elif host == pat:
92
+ return True
93
+ return False
earthdiff/seasonal.py ADDED
@@ -0,0 +1,155 @@
1
+ """Seasonal-cycle check for vegetation loss ("is this just the harvest?").
2
+
3
+ For every vegetation-loss polygon we look at the same two time windows in the previous 1 to ``years`` years. If NDVI fell by a similar amount
4
+ between those earlier windows too, the loss is a recurring annual cycle (harvest, senescence, grazing, irrigation pattern) and the polygon is
5
+ flagged ``likely_seasonal``. Polygons are never removed.
6
+
7
+ Cost control: only the bounding window of the vegetation-loss pixels is read, only red and NIR, at most ``max_scenes`` scenes per window, and
8
+ windows that equal the main before/after windows are reused. Typical extra cost is 2 to 4 small composites.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from typing import Any, Callable
14
+
15
+ import numpy as np
16
+ from affine import Affine
17
+
18
+ from .aoi import Grid
19
+ from .composite import Reader, build_composite
20
+ from .indices import ndvi
21
+ from .periods import Period
22
+
23
+ REPEAT_MIN_DROP = 0.12 # an earlier-year NDVI drop smaller than this does not count as a repeat
24
+ REPEAT_FRACTION = 0.5 # ... and it must be at least this fraction of the current drop
25
+ MIN_VEG_BEFORE = 0.30 # the earlier "before" window must have been vegetated
26
+
27
+
28
+ def subgrid(mask: np.ndarray, grid: Grid, pad: int = 1) -> tuple[Grid, tuple[int, int, int, int]] | None:
29
+ """Smallest grid window (same pixel lattice) that contains every True pixel of ``mask``."""
30
+ if not mask.any():
31
+ return None
32
+ rows, cols = np.flatnonzero(mask.any(axis=1)), np.flatnonzero(mask.any(axis=0))
33
+ r0, r1 = max(int(rows[0]) - pad, 0), min(int(rows[-1]) + 1 + pad, grid.height)
34
+ c0, c1 = max(int(cols[0]) - pad, 0), min(int(cols[-1]) + 1 + pad, grid.width)
35
+ t = grid.transform
36
+ sub = Grid(grid.epsg, Affine(grid.res, 0, t.c + c0 * grid.res, 0, -grid.res, t.f - r0 * grid.res), c1 - c0, r1 - r0, grid.res)
37
+ return sub, (r0, r1, c0, c1)
38
+
39
+
40
+ def prior_windows(before: Period, after: Period, years: int) -> list[tuple[Period, Period]]:
41
+ return [(before.shift_years(-k), after.shift_years(-k)) for k in range(1, years + 1)]
42
+
43
+
44
+ def _mean_by_label(labels: np.ndarray, arr: np.ndarray, n: int) -> tuple[np.ndarray, np.ndarray]:
45
+ ok = np.isfinite(arr) & (labels > 0)
46
+ cnt = np.bincount(labels[ok], minlength=n + 1).astype(np.float64)
47
+ tot = np.bincount(labels[ok], weights=arr[ok].astype(np.float64), minlength=n + 1)
48
+ with np.errstate(invalid="ignore", divide="ignore"):
49
+ mean = tot / cnt
50
+ mean[cnt == 0] = np.nan
51
+ return mean, cnt
52
+
53
+
54
+ def seasonal_check(
55
+ result,
56
+ grid: Grid,
57
+ before: Period,
58
+ after: Period,
59
+ geom,
60
+ backend: str,
61
+ max_cloud: float,
62
+ search: Callable,
63
+ reader: Reader,
64
+ years: int = 2,
65
+ max_scenes: int = 3,
66
+ progress: Callable[[str], None] | None = None,
67
+ ) -> dict[str, Any]:
68
+ """Annotate the vegetation-loss features of ``result`` in place and return a summary dict."""
69
+ say = progress or (lambda m: None)
70
+ t0 = time.monotonic()
71
+ veg_ids = [(f, lid) for f, lid in zip(result.features, result.label_ids) if f["properties"]["class"] == "vegetation_loss"]
72
+ summary: dict[str, Any] = {
73
+ "enabled": True, "years": years, "extra_composites": 0, "windows": [], "checked_polygons": 0, "no_data_polygons": 0,
74
+ "likely_seasonal": {"polygons": 0, "area_ha": 0.0, "share_of_vegetation_loss": 0.0},
75
+ }
76
+ veg_mask = result.classmap == 1
77
+ sg = subgrid(veg_mask, grid)
78
+ if not veg_ids or sg is None:
79
+ summary["elapsed_s"] = round(time.monotonic() - t0, 1)
80
+ return summary
81
+ sub, (r0, r1, c0, c1) = sg
82
+ sl = (slice(r0, r1), slice(c0, c1))
83
+ labels = result.labels[sl].copy()
84
+ veg_labels = {lid for _, lid in veg_ids}
85
+ labels[~np.isin(labels, list(veg_labels))] = 0
86
+ n = int(result.labels.max())
87
+ mask_sub = veg_mask[sl]
88
+
89
+ known = {(before.start, before.end): result.indices_before["ndvi"][sl], (after.start, after.end): result.indices_after["ndvi"][sl]}
90
+
91
+ def ndvi_of(period: Period) -> np.ndarray | None:
92
+ key = (period.start, period.end)
93
+ if key in known:
94
+ return known[key]
95
+ cands = search(backend, geom, period, max_cloud)
96
+ summary["windows"].append(period.label())
97
+ if not cands:
98
+ known[key] = None
99
+ return None
100
+ comp = build_composite(cands, sub, mask_sub, max_scenes, reader=reader, progress=say, label=f"seasonal {period.label()}", bands=("red", "nir"))
101
+ summary["extra_composites"] += 1
102
+ known[key] = ndvi(comp.bands["nir"], comp.bands["red"]) if comp.scenes else None
103
+ return known[key]
104
+
105
+ per_year: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None] = []
106
+ for b_k, a_k in prior_windows(before, after, years):
107
+ say(f"seasonal check: {b_k.label()} -> {a_k.label()}")
108
+ nb_k, na_k = ndvi_of(b_k), ndvi_of(a_k)
109
+ if nb_k is None or na_k is None:
110
+ per_year.append(None)
111
+ continue
112
+ mb, cb = _mean_by_label(labels, nb_k, n)
113
+ ma, ca = _mean_by_label(labels, na_k, n)
114
+ per_year.append((mb, ma, cb, ca, np.bincount(labels.ravel(), minlength=n + 1).astype(np.float64)))
115
+
116
+ total_loss = sum(f["properties"]["area_ha"] for f, _ in veg_ids)
117
+ seasonal_ha = 0.0
118
+ for f, lid in veg_ids:
119
+ p = f["properties"]
120
+ cur_drop = abs(p["ndvi_after"] - p["ndvi_before"])
121
+ checked = repeating = 0
122
+ priors: list[float | None] = []
123
+ for item in per_year:
124
+ if item is None:
125
+ priors.append(None)
126
+ continue
127
+ mb, ma, cb, ca, npix = item
128
+ if not (np.isfinite(mb[lid]) and np.isfinite(ma[lid])) or min(cb[lid], ca[lid]) < max(3.0, 0.3 * npix[lid]):
129
+ priors.append(None)
130
+ continue
131
+ d = float(ma[lid] - mb[lid])
132
+ priors.append(round(d, 3))
133
+ checked += 1
134
+ if mb[lid] >= MIN_VEG_BEFORE and d <= -max(REPEAT_MIN_DROP, REPEAT_FRACTION * cur_drop):
135
+ repeating += 1
136
+ p["seasonal_years_checked"] = checked
137
+ p["seasonal_years_repeating"] = repeating
138
+ p["seasonal_prior_dndvi"] = priors
139
+ if checked == 0:
140
+ p["likely_seasonal"] = None
141
+ summary["no_data_polygons"] += 1
142
+ continue
143
+ summary["checked_polygons"] += 1
144
+ p["likely_seasonal"] = bool(repeating >= 1 and repeating / checked >= 0.5)
145
+ if p["likely_seasonal"]:
146
+ summary["likely_seasonal"]["polygons"] += 1
147
+ seasonal_ha += p["area_ha"]
148
+ summary["likely_seasonal"]["area_ha"] = round(seasonal_ha, 2)
149
+ summary["likely_seasonal"]["share_of_vegetation_loss"] = round(seasonal_ha / total_loss, 3) if total_loss > 0 else 0.0
150
+ summary["method"] = (
151
+ f"For each vegetation-loss polygon the same two windows of the previous {years} year(s) are compared (NDVI, red and NIR only, up to {max_scenes} scenes per window). "
152
+ f"A year repeats the loss when NDVI fell by at least max({REPEAT_MIN_DROP}, {REPEAT_FRACTION} x the current drop). likely_seasonal = repeated in at least half of the years that had data. Polygons are never removed."
153
+ )
154
+ summary["elapsed_s"] = round(time.monotonic() - t0, 1)
155
+ return summary
earthdiff/stac.py ADDED
@@ -0,0 +1,163 @@
1
+ """Sentinel-2 L2A discovery through public STAC APIs (no account, no API key)."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ from shapely.geometry.base import BaseGeometry
10
+
11
+ from .periods import Period
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Backend:
16
+ name: str
17
+ url: str
18
+ collection: str
19
+ # logical band name -> STAC asset key
20
+ assets: dict[str, str]
21
+ attribution: str
22
+ signed: bool = False
23
+
24
+
25
+ BACKENDS: dict[str, Backend] = {
26
+ "earth-search": Backend(
27
+ name="earth-search",
28
+ url="https://earth-search.aws.element84.com/v1",
29
+ collection="sentinel-2-l2a",
30
+ assets={"blue": "blue", "green": "green", "red": "red", "nir": "nir", "swir16": "swir16", "swir22": "swir22", "scl": "scl"},
31
+ attribution="Element 84 Earth Search (Sentinel-2 L2A COGs on AWS Open Data)",
32
+ ),
33
+ "planetary-computer": Backend(
34
+ name="planetary-computer",
35
+ url="https://planetarycomputer.microsoft.com/api/stac/v1",
36
+ collection="sentinel-2-l2a",
37
+ assets={"blue": "B02", "green": "B03", "red": "B04", "nir": "B08", "swir16": "B11", "swir22": "B12", "scl": "SCL"},
38
+ attribution="Microsoft Planetary Computer (Sentinel-2 L2A)",
39
+ signed=True,
40
+ ),
41
+ }
42
+
43
+
44
+ @dataclass
45
+ class Scene:
46
+ id: str
47
+ datetime: datetime
48
+ cloud_cover: float
49
+ tile: str
50
+ hrefs: dict[str, str]
51
+ # logical band -> (scale, offset): reflectance = DN * scale + offset
52
+ radiometry: dict[str, tuple[float, float]] = field(default_factory=dict)
53
+
54
+ @property
55
+ def date(self) -> str:
56
+ return self.datetime.date().isoformat()
57
+
58
+
59
+ class STACError(RuntimeError):
60
+ pass
61
+
62
+
63
+ def _radiometry(item: Any, asset_key: str, backend: str) -> tuple[float, float]:
64
+ """(scale, offset) so that reflectance = DN * scale + offset.
65
+
66
+ ESA processing baseline >= 04.00 adds +1000 to the DN (offset -0.1 must be applied).
67
+ Planetary Computer serves the ESA files as they are. Element84 Earth Search has already
68
+ removed the offset for the reprocessed items (``earthsearch:boa_offset_applied`` = true) even
69
+ though the asset's ``raster:bands`` still lists -0.1 - verified empirically (red DN 1st
70
+ percentile is far below 1000 on baseline 05.xx scenes) - so we must NOT subtract again.
71
+ """
72
+ props = item.properties
73
+ baseline = str(props.get("s2:processing_baseline", "0") or "0")
74
+ try:
75
+ new_baseline = float(baseline) >= 4.0
76
+ except ValueError:
77
+ new_baseline = False
78
+ if backend == "earth-search":
79
+ applied = bool(props.get("earthsearch:boa_offset_applied", False))
80
+ return 1e-4, (-0.1 if (new_baseline and not applied) else 0.0)
81
+ rb = item.assets[asset_key].extra_fields.get("raster:bands") or []
82
+ if rb and "scale" in rb[0]:
83
+ return float(rb[0]["scale"]), float(rb[0].get("offset", 0.0))
84
+ return 1e-4, (-0.1 if new_baseline else 0.0)
85
+
86
+
87
+ def _dedupe(scenes: list[Scene]) -> list[Scene]:
88
+ """Keep one scene per (tile, acquisition date): the highest reprocessing sequence."""
89
+ best: dict[tuple[str, str], Scene] = {}
90
+ for s in scenes:
91
+ key = (s.tile, s.date)
92
+ if key not in best or s.id > best[key].id:
93
+ best[key] = s
94
+ return list(best.values())
95
+
96
+
97
+ def search_scenes(
98
+ backend: str,
99
+ geom_wgs84: BaseGeometry,
100
+ period: Period,
101
+ max_cloud: float = 80.0,
102
+ limit: int = 30,
103
+ retries: int = 3,
104
+ ) -> list[Scene]:
105
+ """Return up to ``limit`` least-cloudy scenes (by scene-level cloud cover) in ``period``."""
106
+ from pystac_client import Client
107
+
108
+ if backend not in BACKENDS:
109
+ raise STACError(f"unknown backend {backend!r}; choose from {', '.join(BACKENDS)}")
110
+ be = BACKENDS[backend]
111
+ modifier = None
112
+ if be.signed:
113
+ try:
114
+ import planetary_computer
115
+ except ImportError as exc: # pragma: no cover
116
+ raise STACError("backend 'planetary-computer' needs: pip install planetary-computer") from exc
117
+ modifier = planetary_computer.sign_inplace
118
+
119
+ last: Exception | None = None
120
+ items: list[Any] = []
121
+ for attempt in range(retries):
122
+ try:
123
+ client = Client.open(be.url, modifier=modifier)
124
+ search = client.search(
125
+ collections=[be.collection],
126
+ bbox=list(geom_wgs84.bounds),
127
+ datetime=period.stac(),
128
+ query={"eo:cloud_cover": {"lt": max_cloud}},
129
+ max_items=400,
130
+ )
131
+ items = list(search.items())
132
+ last = None
133
+ break
134
+ except Exception as exc: # network / API hiccup
135
+ last = exc
136
+ time.sleep(2 * (attempt + 1))
137
+ if last is not None:
138
+ raise STACError(f"STAC search failed ({be.url}): {last}") from last
139
+
140
+ scenes: list[Scene] = []
141
+ for it in items:
142
+ try:
143
+ hrefs = {k: it.assets[v].href for k, v in be.assets.items()}
144
+ except KeyError:
145
+ continue
146
+ # some old Earth Search items point to requester-pays JPEG2000 on s3://; only public COGs are supported
147
+ if not all(h.startswith("http") and h.lower().split("?")[0].endswith((".tif", ".tiff")) for h in hrefs.values()):
148
+ continue
149
+ radio = {k: _radiometry(it, v, backend) for k, v in be.assets.items() if k != "scl"}
150
+ tile = str(it.properties.get("grid:code") or it.properties.get("s2:mgrs_tile") or it.id)
151
+ scenes.append(
152
+ Scene(
153
+ id=it.id,
154
+ datetime=it.datetime,
155
+ cloud_cover=float(it.properties.get("eo:cloud_cover", 100.0)),
156
+ tile=tile,
157
+ hrefs=hrefs,
158
+ radiometry=radio,
159
+ )
160
+ )
161
+ scenes = _dedupe(scenes)
162
+ scenes.sort(key=lambda s: (s.cloud_cover, s.date))
163
+ return scenes[:limit]