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/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 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 – no machine-learning model.</p>
|
|
35
|
+
<table><thead><tr><th>Change class</th><th>Rule (d = after − 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 ≤ 20 m), blobs smaller than the
|
|
40
|
+
<b>minimum mapping unit ({mmu_ha:g} ha)</b> are dropped, and the rest are polygonized (8-connectivity). Areas are pixel counts × pixel area
|
|
41
|
+
({resolution:g} m pixels) on a UTM grid.</p>
|
|
42
|
+
<p><b>Confidence</b> is a heuristic score from 0 to 1 — <i>not</i> a probability: 0.5×magnitude (how far the mean index change exceeds the threshold, saturating at 2×)
|
|
43
|
+
+ 0.3×valid observations (min of before/after, saturating at 3) + 0.2×size (log10 of area / MMU, saturating at 10×MMU). ≥0.70 is “high”, ≥0.45 “medium”.</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 & 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 ≥ 0.25 and NDVI < 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 — 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> “Vegetation loss” says vegetation greenness fell; it does not say why (fire, felling, drought, harvest, construction). “New bare/built-up” 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} m here (SWIR is natively 20 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} ha.</li>
|
|
55
|
+
<li><b>Atmosphere and geometry.</b> Residual haze, sun/view-angle differences between dates and scene co-registration (~10 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]
|