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 ADDED
@@ -0,0 +1,28 @@
1
+ """earthdiff - explainable change reports from free Sentinel-2 imagery."""
2
+
3
+ import os as _os
4
+ from importlib.util import find_spec as _find_spec
5
+
6
+ __version__ = "0.2.0"
7
+
8
+
9
+ def _prefer_bundled_proj_data() -> None:
10
+ """Machines with PostGIS/QGIS/OSGeo4W often export PROJ_LIB/GDAL_DATA pointing at an *older*
11
+ PROJ database, which makes rasterio fail with 'EPSG code is unknown'. The rasterio wheel ships
12
+ consistent data, so point this process (only) at it. Set EARTHDIFF_KEEP_PROJ_ENV=1 to opt out."""
13
+ if _os.environ.get("EARTHDIFF_KEEP_PROJ_ENV"):
14
+ return
15
+ spec = _find_spec("rasterio")
16
+ if not spec or not spec.submodule_search_locations:
17
+ return
18
+ root = list(spec.submodule_search_locations)[0]
19
+ proj = _os.path.join(root, "proj_data")
20
+ gdal = _os.path.join(root, "gdal_data")
21
+ if _os.path.isdir(proj):
22
+ _os.environ["PROJ_DATA"] = proj
23
+ _os.environ["PROJ_LIB"] = proj
24
+ if _os.path.isdir(gdal):
25
+ _os.environ["GDAL_DATA"] = gdal
26
+
27
+
28
+ _prefer_bundled_proj_data()
earthdiff/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
earthdiff/aoi.py ADDED
@@ -0,0 +1,161 @@
1
+ """Area-of-interest handling: GeoJSON / bbox parsing, UTM grid, area."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ from affine import Affine
12
+ from pyproj import Transformer
13
+ from rasterio import features
14
+ from shapely.geometry import box, mapping, shape
15
+ from shapely.geometry.base import BaseGeometry
16
+ from shapely.ops import transform as shp_transform, unary_union
17
+ from shapely.validation import make_valid
18
+
19
+ # One area limit for every entry point (CLI, watch, web UI, MCP server). Large areas are read on a coarser grid
20
+ # (choose_resolution: 10/20/30/60 m, <= 4 M pixels), so memory stays bounded; run time grows with the area.
21
+ MAX_AREA_KM2 = 2000.0
22
+
23
+ WGS84 = "EPSG:4326"
24
+
25
+
26
+ class AOIError(ValueError):
27
+ pass
28
+
29
+
30
+ def bbox_geometry(west: float, south: float, east: float, north: float) -> BaseGeometry:
31
+ if not (-180 <= west < east <= 180 and -90 <= south < north <= 90):
32
+ raise AOIError("bbox must be west,south,east,north in WGS84 degrees with west<east and south<north")
33
+ return box(west, south, east, north)
34
+
35
+
36
+ def parse_bbox(text: str) -> BaseGeometry:
37
+ try:
38
+ w, s, e, n = (float(x) for x in text.replace(" ", "").split(","))
39
+ except ValueError as exc:
40
+ raise AOIError("bbox must be 'west,south,east,north'") from exc
41
+ return bbox_geometry(w, s, e, n)
42
+
43
+
44
+ def geometry_from_geojson(obj: dict[str, Any]) -> BaseGeometry:
45
+ """Accept FeatureCollection / Feature / bare geometry; return the union of its polygons."""
46
+ kind = obj.get("type")
47
+ if kind == "FeatureCollection":
48
+ geoms = [shape(f["geometry"]) for f in obj.get("features", []) if f.get("geometry")]
49
+ elif kind == "Feature":
50
+ geoms = [shape(obj["geometry"])]
51
+ elif kind in {"Polygon", "MultiPolygon"}:
52
+ geoms = [shape(obj)]
53
+ else:
54
+ raise AOIError(f"unsupported GeoJSON type: {kind!r}")
55
+ polys = []
56
+ for g in geoms:
57
+ if g.geom_type == "Polygon":
58
+ polys.append(g)
59
+ elif g.geom_type == "MultiPolygon":
60
+ polys.extend(g.geoms)
61
+ if not polys:
62
+ raise AOIError("the GeoJSON contains no Polygon/MultiPolygon geometry")
63
+ geom = _polygons_only(unary_union([make_valid(p) for p in polys]))
64
+ if geom.is_empty:
65
+ raise AOIError("AOI geometry is empty")
66
+ minx, miny, maxx, maxy = geom.bounds
67
+ if not (-180 <= minx and maxx <= 180 and -90 <= miny and maxy <= 90):
68
+ raise AOIError("AOI coordinates must be WGS84 longitude/latitude (EPSG:4326)")
69
+ return geom
70
+
71
+
72
+ def _polygons_only(geom: BaseGeometry) -> BaseGeometry:
73
+ if geom.geom_type in {"Polygon", "MultiPolygon"}:
74
+ return geom
75
+ parts = [g for g in getattr(geom, "geoms", []) if g.geom_type in {"Polygon", "MultiPolygon"}]
76
+ return unary_union(parts) if parts else geom.buffer(0)
77
+
78
+
79
+ def load_aoi(path: str | Path) -> BaseGeometry:
80
+ with open(path, "r", encoding="utf-8") as fh:
81
+ return geometry_from_geojson(json.load(fh))
82
+
83
+
84
+ def utm_epsg(lon: float, lat: float) -> int:
85
+ if lat >= 84 or lat <= -80:
86
+ raise AOIError("polar areas (beyond 84N / 80S) are not supported (UTM)")
87
+ zone = int((lon + 180) // 6) + 1
88
+ zone = min(max(zone, 1), 60)
89
+ return (32600 if lat >= 0 else 32700) + zone
90
+
91
+
92
+ def to_crs(geom: BaseGeometry, src: str, dst: str) -> BaseGeometry:
93
+ t = Transformer.from_crs(src, dst, always_xy=True)
94
+ return shp_transform(t.transform, geom)
95
+
96
+
97
+ def area_km2(geom_wgs84: BaseGeometry) -> float:
98
+ c = geom_wgs84.representative_point()
99
+ utm = to_crs(geom_wgs84, WGS84, f"EPSG:{utm_epsg(c.x, c.y)}")
100
+ return utm.area / 1e6
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class Grid:
105
+ """Analysis grid: a UTM raster window at a fixed resolution."""
106
+
107
+ epsg: int
108
+ transform: Affine
109
+ width: int
110
+ height: int
111
+ res: float
112
+
113
+ @property
114
+ def crs(self) -> str:
115
+ return f"EPSG:{self.epsg}"
116
+
117
+ @property
118
+ def shape(self) -> tuple[int, int]:
119
+ return (self.height, self.width)
120
+
121
+ @property
122
+ def bounds(self) -> tuple[float, float, float, float]:
123
+ t = self.transform
124
+ return (t.c, t.f + t.e * self.height, t.c + t.a * self.width, t.f)
125
+
126
+ @property
127
+ def pixel_area_ha(self) -> float:
128
+ return self.res * self.res / 10_000.0
129
+
130
+
131
+ def _utm_bounds(geom_wgs84: BaseGeometry) -> tuple[int, tuple[float, float, float, float]]:
132
+ c = geom_wgs84.representative_point()
133
+ epsg = utm_epsg(c.x, c.y)
134
+ return epsg, to_crs(geom_wgs84, WGS84, f"EPSG:{epsg}").bounds
135
+
136
+
137
+ def choose_resolution(geom_wgs84: BaseGeometry, max_pixels: int = 4_000_000) -> float:
138
+ """Smallest of 10/20/30/60 m so that the window stays within ``max_pixels``."""
139
+ _, (minx, miny, maxx, maxy) = _utm_bounds(geom_wgs84)
140
+ w, h = maxx - minx, maxy - miny
141
+ for res in (10.0, 20.0, 30.0, 60.0):
142
+ if (w / res) * (h / res) <= max_pixels:
143
+ return res
144
+ return 60.0
145
+
146
+
147
+ def make_grid(geom_wgs84: BaseGeometry, res: float) -> Grid:
148
+ epsg, (minx, miny, maxx, maxy) = _utm_bounds(geom_wgs84)
149
+ x0 = math.floor(minx / res) * res
150
+ y1 = math.ceil(maxy / res) * res
151
+ width = max(1, int(math.ceil((maxx - x0) / res)))
152
+ height = max(1, int(math.ceil((y1 - miny) / res)))
153
+ return Grid(epsg, Affine(res, 0, x0, 0, -res, y1), width, height, res)
154
+
155
+
156
+ def aoi_mask(geom_wgs84: BaseGeometry, grid: Grid) -> np.ndarray:
157
+ """Boolean raster, True where the pixel centre lies inside the AOI polygon."""
158
+ utm = to_crs(geom_wgs84, WGS84, grid.crs)
159
+ return features.rasterize(
160
+ [(mapping(utm), 1)], out_shape=grid.shape, transform=grid.transform, fill=0, dtype="uint8"
161
+ ).astype(bool)
earthdiff/cause.py ADDED
@@ -0,0 +1,144 @@
1
+ """Likely cause of vegetation loss: fire-like, clearing-like or unclear.
2
+
3
+ This is a *hint*, never a verdict. Vegetation loss is first found by the NDVI rule (``change.py``); here each polygon is described by
4
+ a few measurements and compared with published thresholds:
5
+
6
+ * **dNBR** (differenced Normalized Burn Ratio, NBR = (NIR - SWIR2) / (NIR + SWIR2), Sentinel-2 B08 and B12; dNBR = NBR before - NBR after).
7
+ Burn-severity classes of Key & Benson (2006), "Landscape assessment (LA)", FIREMON, USDA Forest Service RMRS-GTR-164-CD, as used by the
8
+ Copernicus EFFIS fire-severity product (https://forest-fire.emergency.copernicus.eu/about-effis/technical-background/fire-severity)
9
+ and described by UN-SPIDER (burn severity recommended practice):
10
+ < 0.10 unburned or regrowth, 0.10-0.27 low, 0.27-0.44 moderate-low, 0.44-0.66 moderate-high, >= 0.66 high severity.
11
+ The thresholds were derived for Landsat in specific ecosystems and vary between regions and land covers, so they are indicative only.
12
+ * **Post-event brightness** and **shape** are NOT taken from a standard: burn scars are usually darker (char, ash) than the bare soil that
13
+ fields and clear-cuts expose, and clearings and fields have straight edges (area close to the area of the minimum rotated rectangle).
14
+ These thresholds are ours (``DARK_VIS_AFTER``, ``BRIGHT_VIS_AFTER``, ``RECT_MIN``, ``IRREGULAR_MAX``), set by looking at two public cases only (Manavgat 2021 fire, Istanbul Airport 2017-2019 construction; see validation/)
15
+ and may not transfer to other regions, seasons or fire ages (old, ashy or grey burn scars are brighter than fresh char).
16
+
17
+ Labels (stored in ``likely_cause`` of the feature, with the measurements and a plain-language basis):
18
+ ``fire-like`` dNBR at least moderate-low AND a dark post-event surface AND more evidence for fire than for clearing
19
+ ``clearing-like`` little or no burn signal, or straight-edged and bright, i.e. more evidence for clearing, harvest or construction
20
+ ``unclear`` mixed or weak evidence (the default whenever the measurements disagree)
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ import numpy as np
27
+
28
+ from .aoi import Grid
29
+
30
+ DNBR_LOW = 0.10 # Key & Benson 2006: below this: unburned / regrowth
31
+ DNBR_MODERATE_LOW = 0.27 # Key & Benson 2006: lower bound of "moderate-low" severity
32
+ DNBR_MODERATE_HIGH = 0.44 # Key & Benson 2006: lower bound of "moderate-high" severity
33
+ DARK_VIS_AFTER = 0.09 # ours: mean(blue, green, red) after, at or below = dark surface (char / ash). Manavgat median 0.055, Istanbul Airport 5th percentile 0.092
34
+ BRIGHT_VIS_AFTER = 0.20 # ours: at or above = bright bare surface (soil, stubble, concrete)
35
+ RECT_MIN = 0.80 # ours: area / minimum rotated rectangle at or above = straight-edged (field, clear-cut, site)
36
+ IRREGULAR_MAX = 0.60 # ours: at or below = irregular outline
37
+
38
+ LABELS = ("fire-like", "clearing-like", "unclear")
39
+ SOURCES = [
40
+ "Key & Benson (2006) FIREMON Landscape Assessment: dNBR severity thresholds 0.10 / 0.27 / 0.44 / 0.66",
41
+ "Copernicus EFFIS fire severity (same thresholds applied to Sentinel-2): https://forest-fire.emergency.copernicus.eu/about-effis/technical-background/fire-severity",
42
+ "UN-SPIDER recommended practice: burn severity mapping with NBR / dNBR",
43
+ ]
44
+
45
+
46
+ def classify_cause(m: dict[str, float]) -> tuple[str, list[str]]:
47
+ """``m`` holds dnbr, vis_after, vis_delta, rectangularity, area_ha. Returns (label, basis sentences)."""
48
+ fire: list[str] = []
49
+ clear: list[str] = []
50
+ d = m["dnbr"]
51
+ if d is None or not np.isfinite(d):
52
+ return "unclear", ["no SWIR2 (B12) measurement, so no burn signal could be computed"]
53
+ if d >= DNBR_MODERATE_HIGH:
54
+ fire.append(f"dNBR {d:.2f} (at least moderate-high burn severity by Key & Benson thresholds)")
55
+ elif d >= DNBR_MODERATE_LOW:
56
+ fire.append(f"dNBR {d:.2f} (at least moderate-low burn severity)")
57
+ elif d < DNBR_LOW:
58
+ clear.append(f"dNBR {d:.2f} (below 0.10: no burn signal)")
59
+ else:
60
+ clear.append(f"dNBR {d:.2f} (low, 0.10 to 0.27: weak burn signal)")
61
+ if m["vis_after"] <= DARK_VIS_AFTER:
62
+ fire.append(f"dark after (visible reflectance {m['vis_after']:.2f}), like char or ash")
63
+ elif m["vis_after"] >= BRIGHT_VIS_AFTER:
64
+ clear.append(f"bright after (visible reflectance {m['vis_after']:.2f}), like exposed soil or stubble")
65
+ r = m["rectangularity"]
66
+ if r >= RECT_MIN:
67
+ clear.append(f"straight-edged outline (rectangularity {r:.2f})")
68
+ elif r <= IRREGULAR_MAX:
69
+ fire.append(f"irregular outline (rectangularity {r:.2f})")
70
+
71
+ burn_signal = d >= DNBR_MODERATE_LOW
72
+ dark = m["vis_after"] <= DARK_VIS_AFTER
73
+ if burn_signal and dark and len(fire) > len(clear): # dNBR alone is not enough (harvest and clearing raise it too): the surface must also be dark
74
+ return "fire-like", fire + clear
75
+ if (not burn_signal) and len(clear) >= len(fire) and len(clear) >= 1:
76
+ return "clearing-like", clear + fire
77
+ if burn_signal and len(clear) > len(fire):
78
+ return "clearing-like", clear + fire
79
+ return "unclear", fire + clear
80
+
81
+
82
+ def _bincount_mean(labels_flat: np.ndarray, arr_flat: np.ndarray, n: int) -> np.ndarray:
83
+ ok = np.isfinite(arr_flat)
84
+ cnt = np.bincount(labels_flat[ok], minlength=n + 1).astype(np.float64)
85
+ tot = np.bincount(labels_flat[ok], weights=arr_flat[ok].astype(np.float64), minlength=n + 1)
86
+ with np.errstate(invalid="ignore", divide="ignore"):
87
+ out = tot / cnt
88
+ out[cnt == 0] = np.nan
89
+ return out
90
+
91
+
92
+ def label_causes(result, before_bands: dict[str, np.ndarray], after_bands: dict[str, np.ndarray]) -> dict[str, Any] | None:
93
+ """Add ``likely_cause`` and its measurements to the vegetation-loss features of ``result`` (in place).
94
+
95
+ Returns a summary dict (hectares per label, method and sources) or ``None`` when B12 is unavailable (then the features get no label)."""
96
+ nb, na = result.indices_before.get("nbr"), result.indices_after.get("nbr")
97
+ if nb is None or na is None:
98
+ return None
99
+ n = int(result.labels.max())
100
+ if n == 0:
101
+ return _summary({})
102
+ sel = result.labels > 0
103
+ lab = result.labels[sel]
104
+ dnbr = _bincount_mean(lab, (nb - na)[sel], n)
105
+ vis_a = _bincount_mean(lab, ((after_bands["blue"] + after_bands["green"] + after_bands["red"]) / 3.0)[sel], n)
106
+ vis_b = _bincount_mean(lab, ((before_bands["blue"] + before_bands["green"] + before_bands["red"]) / 3.0)[sel], n)
107
+ totals: dict[str, dict[str, float]] = {k: {"polygons": 0, "area_ha": 0.0} for k in LABELS}
108
+ for f, lid in zip(result.features, result.label_ids):
109
+ p = f["properties"]
110
+ if p["class"] != "vegetation_loss":
111
+ continue
112
+ shape = result.shape_stats.get(lid, {})
113
+ m = {
114
+ "dnbr": float(dnbr[lid]) if np.isfinite(dnbr[lid]) else None,
115
+ "vis_after": float(vis_a[lid]),
116
+ "vis_delta": float(vis_a[lid] - vis_b[lid]),
117
+ "rectangularity": float(shape.get("rectangularity", 0.0)),
118
+ "area_ha": float(p["area_ha"]),
119
+ }
120
+ label, basis = classify_cause(m)
121
+ p["likely_cause"] = label
122
+ p["likely_cause_basis"] = "; ".join(basis)
123
+ p["dnbr"] = None if m["dnbr"] is None else round(m["dnbr"], 3)
124
+ p["vis_reflectance_after"] = round(m["vis_after"], 3)
125
+ p["rectangularity"] = round(m["rectangularity"], 3)
126
+ totals[label]["polygons"] += 1
127
+ totals[label]["area_ha"] = round(totals[label]["area_ha"] + p["area_ha"], 2)
128
+ return _summary(totals)
129
+
130
+
131
+ def _summary(totals: dict[str, dict[str, float]]) -> dict[str, Any]:
132
+ full = {k: totals.get(k, {"polygons": 0, "area_ha": 0.0}) for k in LABELS}
133
+ return {
134
+ "applies_to": "vegetation_loss polygons",
135
+ "totals": full,
136
+ "note": "A hint from dNBR, brightness and shape, not a verdict on the cause. 'fire-like' does not prove a fire; 'clearing-like' covers felling, harvest, ploughing and construction.",
137
+ "thresholds": {
138
+ "dnbr_unburned_below": DNBR_LOW, "dnbr_moderate_low": DNBR_MODERATE_LOW, "dnbr_moderate_high": DNBR_MODERATE_HIGH,
139
+ "dark_visible_reflectance_after_at_most": DARK_VIS_AFTER, "bright_visible_reflectance_after_at_least": BRIGHT_VIS_AFTER,
140
+ "straight_edged_rectangularity_at_least": RECT_MIN, "irregular_rectangularity_at_most": IRREGULAR_MAX,
141
+ },
142
+ "sources": SOURCES,
143
+ "own_thresholds": "brightness and rectangularity thresholds are this project's heuristics, checked on only two public cases",
144
+ }
earthdiff/change.py ADDED
@@ -0,0 +1,337 @@
1
+ """Rule-based change classification, minimum mapping unit, polygonization, confidence."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ from pyproj import Transformer
10
+ from rasterio import features
11
+ from scipy.ndimage import binary_opening, label
12
+ from shapely.geometry import mapping, shape
13
+ from shapely.ops import transform as shp_transform
14
+
15
+ from .aoi import WGS84, Grid
16
+ from .composite import Composite
17
+ from .indices import compute_indices, ndvi, normalized_difference
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ClassInfo:
22
+ id: int
23
+ key: str
24
+ label: str
25
+ color: str # colour-blind friendly (Okabe-Ito)
26
+ rule: str
27
+
28
+
29
+ CLASSES: dict[int, ClassInfo] = {
30
+ 1: ClassInfo(1, "vegetation_loss", "Vegetation loss", "#D55E00",
31
+ "NDVI before >= veg_min_before AND dNDVI <= -veg_drop AND not water after"),
32
+ 2: ClassInfo(2, "vegetation_gain", "Vegetation gain", "#009E73",
33
+ "NDVI after >= veg_min_after AND dNDVI >= +veg_rise AND not water before"),
34
+ 3: ClassInfo(3, "new_water", "New water", "#0072B2",
35
+ "water after (MNDWI > water_mndwi, NDVI < 0.30) AND MNDWI before < land_mndwi AND dMNDWI >= water_delta"),
36
+ 4: ClassInfo(4, "water_loss", "Water loss", "#F0E442",
37
+ "water before AND MNDWI after < land_mndwi AND dMNDWI <= -water_delta"),
38
+ 5: ClassInfo(5, "new_bare_or_built", "New bare soil / built-up", "#CC79A7",
39
+ "dBSI >= bare_rise AND NDVI after < bare_ndvi_max AND no water before/after"),
40
+ }
41
+ KEY_TO_ID = {c.key: c.id for c in CLASSES.values()}
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Thresholds:
46
+ veg_min_before: float = 0.40
47
+ veg_drop: float = 0.25
48
+ veg_min_after: float = 0.40
49
+ veg_rise: float = 0.25
50
+ water_mndwi: float = 0.10
51
+ land_mndwi: float = -0.05
52
+ water_ndvi_max: float = 0.30
53
+ water_delta: float = 0.25
54
+ bare_rise: float = 0.15
55
+ bare_ndvi_max: float = 0.25
56
+
57
+ def as_dict(self) -> dict[str, float]:
58
+ return asdict(self)
59
+
60
+
61
+ SENSITIVITY = {
62
+ "strict": dict(veg_drop=0.35, veg_rise=0.35, water_delta=0.35, bare_rise=0.22),
63
+ "default": {},
64
+ "sensitive": dict(veg_drop=0.18, veg_rise=0.18, water_delta=0.18, bare_rise=0.10),
65
+ }
66
+
67
+
68
+ def thresholds_for(sensitivity: str = "default", **overrides: float) -> Thresholds:
69
+ if sensitivity not in SENSITIVITY:
70
+ raise ValueError(f"sensitivity must be one of {', '.join(SENSITIVITY)}")
71
+ params = {**SENSITIVITY[sensitivity], **overrides}
72
+ return Thresholds(**params)
73
+
74
+
75
+ def classify(before: dict[str, np.ndarray], after: dict[str, np.ndarray], thr: Thresholds) -> np.ndarray:
76
+ """Per-pixel class id (uint8; 0 = no change). ``before``/``after`` hold ndvi, mndwi, bsi."""
77
+ nb, na = before["ndvi"], after["ndvi"]
78
+ mb, ma = before["mndwi"], after["mndwi"]
79
+ bb, ba = before["bsi"], after["bsi"]
80
+ valid = np.isfinite(nb) & np.isfinite(na) & np.isfinite(mb) & np.isfinite(ma) & np.isfinite(bb) & np.isfinite(ba)
81
+
82
+ with np.errstate(invalid="ignore"):
83
+ dn, dm, db = na - nb, ma - mb, ba - bb
84
+ water_a = (ma > thr.water_mndwi) & (na < thr.water_ndvi_max)
85
+ water_b = (mb > thr.water_mndwi) & (nb < thr.water_ndvi_max)
86
+
87
+ new_water = water_a & (mb < thr.land_mndwi) & (dm >= thr.water_delta)
88
+ water_loss = water_b & (ma < thr.land_mndwi) & (dm <= -thr.water_delta)
89
+ veg_loss = (nb >= thr.veg_min_before) & (dn <= -thr.veg_drop) & ~water_a
90
+ veg_gain = (na >= thr.veg_min_after) & (dn >= thr.veg_rise) & ~water_b
91
+ bare = (db >= thr.bare_rise) & (na < thr.bare_ndvi_max) & ~water_a & ~water_b
92
+
93
+ out = np.zeros(nb.shape, dtype=np.uint8)
94
+ # lowest priority first; later assignments overwrite earlier ones
95
+ out[bare & valid] = 5
96
+ out[veg_gain & valid] = 2
97
+ out[veg_loss & valid] = 1
98
+ out[water_loss & valid] = 4
99
+ out[new_water & valid] = 3
100
+ return out
101
+
102
+
103
+ def label_components(
104
+ classmap: np.ndarray, res: float, mmu_ha: float, open_noise: bool = True
105
+ ) -> tuple[np.ndarray, np.ndarray]:
106
+ """Clean each class (opening for <=20 m pixels), drop blobs below the MMU, label the rest.
107
+
108
+ Returns ``(labels, label_class)`` where ``label_class[label] -> class id`` (index 0 unused).
109
+ """
110
+ labels = np.zeros(classmap.shape, dtype=np.int32)
111
+ label_class = [0]
112
+ min_pix = max(1, int(math.ceil(mmu_ha * 10_000.0 / (res * res))))
113
+ struct8 = np.ones((3, 3), dtype=bool)
114
+ square3 = np.ones((3, 3), dtype=bool)
115
+ next_id = 1
116
+ for cid in CLASSES:
117
+ m = classmap == cid
118
+ if not m.any():
119
+ continue
120
+ if open_noise and res <= 20.0:
121
+ m = binary_opening(m, structure=square3)
122
+ if not m.any():
123
+ continue
124
+ lab, n = label(m, structure=struct8)
125
+ sizes = np.bincount(lab.ravel(), minlength=n + 1)
126
+ keep = sizes >= min_pix
127
+ keep[0] = False
128
+ kept = np.flatnonzero(keep)
129
+ if kept.size == 0:
130
+ continue
131
+ remap = np.zeros(n + 1, dtype=np.int32)
132
+ remap[kept] = np.arange(next_id, next_id + kept.size, dtype=np.int32)
133
+ sel = keep[lab]
134
+ labels[sel] = remap[lab[sel]]
135
+ label_class.extend([cid] * int(kept.size))
136
+ next_id += int(kept.size)
137
+ return labels, np.array(label_class, dtype=np.uint8)
138
+
139
+
140
+ def _primary(cid: int, mean_b: dict[str, np.ndarray], mean_a: dict[str, np.ndarray], thr: Thresholds):
141
+ """(delta, threshold) that defines each class; used for the magnitude part of the confidence."""
142
+ dn = mean_a["ndvi"] - mean_b["ndvi"]
143
+ dm = mean_a["mndwi"] - mean_b["mndwi"]
144
+ db = mean_a["bsi"] - mean_b["bsi"]
145
+ return {
146
+ 1: (-dn, thr.veg_drop),
147
+ 2: (dn, thr.veg_rise),
148
+ 3: (dm, thr.water_delta),
149
+ 4: (-dm, thr.water_delta),
150
+ 5: (db, thr.bare_rise),
151
+ }[cid]
152
+
153
+
154
+ def confidence_score(magnitude_ratio: float, obs: float, area_ha: float, mmu_ha: float) -> float:
155
+ """Heuristic 0..1 (NOT a statistical probability).
156
+
157
+ 0.5 * magnitude + 0.3 * valid observations + 0.2 * size, where
158
+ magnitude = clip((delta - threshold) / threshold, 0, 1) (delta of 2x threshold -> 1)
159
+ obs = clip(min(valid obs before, after) / 3, 0, 1)
160
+ size = clip(log10(area / MMU), 0, 1) (10x the MMU -> 1)
161
+ """
162
+ mag = min(max(magnitude_ratio, 0.0), 1.0)
163
+ o = min(max(obs / 3.0, 0.0), 1.0)
164
+ size = min(max(math.log10(max(area_ha, 1e-9) / mmu_ha), 0.0), 1.0)
165
+ return 0.5 * mag + 0.3 * o + 0.2 * size
166
+
167
+
168
+ def confidence_level(score: float) -> str:
169
+ return "high" if score >= 0.70 else "medium" if score >= 0.45 else "low"
170
+
171
+
172
+ @dataclass
173
+ class ChangeResult:
174
+ classmap: np.ndarray # cleaned, uint8
175
+ labels: np.ndarray
176
+ features: list[dict[str, Any]]
177
+ indices_before: dict[str, np.ndarray]
178
+ indices_after: dict[str, np.ndarray]
179
+ valid: np.ndarray # bool, comparable pixels inside the AOI
180
+ totals: dict[str, dict[str, float]] = field(default_factory=dict)
181
+ label_ids: list[int] = field(default_factory=list) # raster label of each feature (same order as ``features``)
182
+ shape_stats: dict[int, dict[str, float]] = field(default_factory=dict)
183
+ bright_fraction: float = 0.0 # share of comparable pixels that are very bright in the before OR after composite
184
+ bright_both_fraction: float = 0.0 # ... in BOTH composites
185
+ bright_ndwi: dict[str, float] | None = None # NDWI > 0 share of the bright pixels before / after (exploratory wet/dry signal), None if no bright pixels
186
+
187
+
188
+ def bright_mask(bands: dict[str, np.ndarray], vis_min: float = 0.25, ndvi_max: float = 0.15) -> np.ndarray:
189
+ """Very bright, non-vegetated surface (salt pan, sand, snow, bare limestone, concrete): mean visible reflectance >= ``vis_min``
190
+ and NDVI < ``ndvi_max``. Such surfaces saturate true-colour images and the water/vegetation rules are unreliable on them."""
191
+ vis = (bands["blue"] + bands["green"] + bands["red"]) / 3.0
192
+ nd = ndvi(bands["nir"], bands["red"])
193
+ with np.errstate(invalid="ignore"):
194
+ return np.isfinite(vis) & (vis >= vis_min) & (nd < ndvi_max)
195
+
196
+
197
+ def polygonize(*args, **kwargs) -> list[dict[str, Any]]:
198
+ """Polygons as GeoJSON features (see :func:`polygonize_detailed` for the label ids and shape statistics)."""
199
+ return polygonize_detailed(*args, **kwargs)[0]
200
+
201
+
202
+ def polygonize_detailed(
203
+ labels: np.ndarray,
204
+ label_class: np.ndarray,
205
+ grid: Grid,
206
+ ib: dict[str, np.ndarray],
207
+ ia: dict[str, np.ndarray],
208
+ obs_b: np.ndarray,
209
+ obs_a: np.ndarray,
210
+ thr: Thresholds,
211
+ mmu_ha: float,
212
+ bright_b: np.ndarray | None = None,
213
+ bright_a: np.ndarray | None = None,
214
+ ) -> tuple[list[dict[str, Any]], list[int], dict[int, dict[str, float]]]:
215
+ """Like :func:`polygonize` but also returns ``label_ids`` (the raster label of each feature, same order) and per-label
216
+ shape statistics (``rectangularity`` = area / minimum rotated rectangle area, ``solidity`` = area / convex hull area)."""
217
+ n = int(labels.max())
218
+ if n == 0:
219
+ return [], [], {}
220
+ sel = labels > 0
221
+ lab = labels[sel]
222
+ npix = np.bincount(lab, minlength=n + 1).astype(np.float64)
223
+ denom = np.maximum(npix, 1.0)
224
+
225
+ def mean_of(arr: np.ndarray) -> np.ndarray:
226
+ return np.bincount(lab, weights=np.nan_to_num(arr[sel].astype(np.float64)), minlength=n + 1) / denom
227
+
228
+ mb = {k: mean_of(ib[k]) for k in ("ndvi", "mndwi", "bsi")}
229
+ ma = {k: mean_of(ia[k]) for k in ("ndvi", "mndwi", "bsi")}
230
+ ob, oa = mean_of(obs_b), mean_of(obs_a)
231
+
232
+ to_wgs = Transformer.from_crs(grid.crs, WGS84, always_xy=True).transform
233
+ shapes_iter = features.shapes(labels, mask=sel, transform=grid.transform, connectivity=8)
234
+ parts: dict[int, list[dict]] = {}
235
+ for geom, value in shapes_iter:
236
+ parts.setdefault(int(value), []).append(geom)
237
+
238
+ bsh_b = mean_of(bright_b.astype(np.float32)) if bright_b is not None else None
239
+ bsh_a = mean_of(bright_a.astype(np.float32)) if bright_a is not None else None
240
+ out: list[tuple[dict[str, Any], int]] = []
241
+ shapes: dict[int, dict[str, float]] = {}
242
+ for lid in sorted(parts):
243
+ cid = int(label_class[lid])
244
+ info = CLASSES[cid]
245
+ area_ha = float(npix[lid] * grid.pixel_area_ha)
246
+ delta_arr, t = _primary(cid, {k: v[lid] for k, v in mb.items()}, {k: v[lid] for k, v in ma.items()}, thr)
247
+ mag_ratio = (float(delta_arr) - t) / t
248
+ obs = float(min(ob[lid], oa[lid]))
249
+ score = confidence_score(mag_ratio, obs, area_ha, mmu_ha)
250
+ bright_share = 0.0
251
+ if bsh_b is not None and bsh_a is not None:
252
+ bright_share = float(max(bsh_b[lid], bsh_a[lid]))
253
+ score *= 1.0 - 0.5 * bright_share # confidence is lowered where the surface is very bright (salt, sand, snow)
254
+ utm_geom = shape(parts[lid][0])
255
+ mrr, hull = utm_geom.minimum_rotated_rectangle.area, utm_geom.convex_hull.area
256
+ shapes[lid] = {
257
+ "rectangularity": round(float(utm_geom.area / mrr), 3) if mrr > 0 else 0.0,
258
+ "solidity": round(float(utm_geom.area / hull), 3) if hull > 0 else 0.0,
259
+ }
260
+ geom = shp_transform(to_wgs, utm_geom)
261
+ cen = geom.representative_point()
262
+ out.append(
263
+ ({
264
+ "type": "Feature",
265
+ "geometry": mapping(_round_geom(geom)),
266
+ "properties": {
267
+ "id": len(out) + 1,
268
+ "class": info.key,
269
+ "class_label": info.label,
270
+ "area_ha": round(area_ha, 2),
271
+ "confidence": round(score, 2),
272
+ "confidence_level": confidence_level(score),
273
+ "ndvi_before": round(float(mb["ndvi"][lid]), 3),
274
+ "ndvi_after": round(float(ma["ndvi"][lid]), 3),
275
+ "mndwi_before": round(float(mb["mndwi"][lid]), 3),
276
+ "mndwi_after": round(float(ma["mndwi"][lid]), 3),
277
+ "bsi_before": round(float(mb["bsi"][lid]), 3),
278
+ "bsi_after": round(float(ma["bsi"][lid]), 3),
279
+ "valid_obs_before": round(float(ob[lid]), 1),
280
+ "valid_obs_after": round(float(oa[lid]), 1),
281
+ "centroid_lon": round(cen.x, 6),
282
+ "centroid_lat": round(cen.y, 6),
283
+ "bright_share": round(bright_share, 3),
284
+ },
285
+ }, lid)
286
+ )
287
+ out.sort(key=lambda t: -t[0]["properties"]["area_ha"])
288
+ for i, (f, _) in enumerate(out, 1):
289
+ f["properties"]["id"] = i
290
+ return [f for f, _ in out], [lid for _, lid in out], shapes
291
+
292
+
293
+ def _round_geom(geom, nd: int = 6):
294
+ from shapely import set_precision
295
+
296
+ return set_precision(geom, 10 ** -nd)
297
+
298
+
299
+ def detect_changes(
300
+ before: Composite,
301
+ after: Composite,
302
+ grid: Grid,
303
+ mask: np.ndarray,
304
+ thr: Thresholds,
305
+ mmu_ha: float = 0.5,
306
+ ) -> ChangeResult:
307
+ ib = compute_indices(before.bands)
308
+ ia = compute_indices(after.bands)
309
+ valid = mask & (before.count > 0) & (after.count > 0)
310
+ for d in (ib, ia):
311
+ for arr in d.values():
312
+ arr[~valid] = np.nan
313
+ raw = classify(ib, ia, thr)
314
+ labels, label_class = label_components(raw, grid.res, mmu_ha)
315
+ del raw
316
+ bright_b, bright_a = bright_mask(before.bands) & valid, bright_mask(after.bands) & valid
317
+ n_valid = max(1, int(valid.sum()))
318
+ bright_fraction = float((bright_b | bright_a).sum()) / n_valid
319
+ bright_both = float((bright_b & bright_a).sum()) / n_valid
320
+ bright_ndwi = None
321
+ ref = bright_b | bright_a
322
+ if int(ref.sum()) >= 100:
323
+ bright_ndwi = {"pixels": int(ref.sum())}
324
+ for tag, comp in (("before", before), ("after", after)):
325
+ nd = normalized_difference(comp.bands["green"], comp.bands["nir"])
326
+ bright_ndwi[tag] = round(float(((nd > 0) & ref).sum()) / int(ref.sum()), 3)
327
+ feats, label_ids, shapes = polygonize_detailed(labels, label_class, grid, ib, ia, before.count, after.count, thr, mmu_ha, bright_b, bright_a)
328
+ classmap = label_class[labels] if labels.size else np.zeros_like(labels, dtype=np.uint8)
329
+ totals: dict[str, dict[str, float]] = {}
330
+ for info in CLASSES.values():
331
+ subset = [f for f in feats if f["properties"]["class"] == info.key]
332
+ totals[info.key] = {
333
+ "label": info.label,
334
+ "polygons": len(subset),
335
+ "area_ha": round(sum(f["properties"]["area_ha"] for f in subset), 2),
336
+ }
337
+ return ChangeResult(classmap.astype(np.uint8), labels, feats, ib, ia, valid, totals, label_ids, shapes, bright_fraction, bright_both, bright_ndwi)