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/__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
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)
|