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