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/xlsx.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""A small, dependency-free .xlsx writer (Office Open XML) for the change report, and the workbook earthdiff writes.
|
|
2
|
+
|
|
3
|
+
Sheets: summary (what was compared, totals, warnings), changes (one row per polygon, with a plain-language
|
|
4
|
+
description and a map link) and scenes. Numbers are real numbers in Excel, so they can be sorted, filtered and summed."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import re
|
|
9
|
+
import zipfile
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
from xml.sax.saxutils import escape
|
|
12
|
+
|
|
13
|
+
from .i18n import CAUSE_SHORT, STRENGTH, class_label, describe
|
|
14
|
+
|
|
15
|
+
_BAD = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
|
|
16
|
+
_SHEET_BAD = re.compile(r"[\[\]:*?/\\]") # characters Excel does not allow in sheet names
|
|
17
|
+
H, NUM2, NUM3, LINK, WRAP = 1, 2, 3, 4, 5 # cell styles
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Link:
|
|
21
|
+
def __init__(self, url: str, text: str):
|
|
22
|
+
self.url, self.text = url, text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Styled:
|
|
26
|
+
def __init__(self, value: Any, style: int):
|
|
27
|
+
self.value, self.style = value, style
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _col(i: int) -> str:
|
|
31
|
+
s = ""
|
|
32
|
+
i += 1
|
|
33
|
+
while i:
|
|
34
|
+
i, r = divmod(i - 1, 26)
|
|
35
|
+
s = chr(65 + r) + s
|
|
36
|
+
return s
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cell(ref: str, v: Any, style: int = 0) -> str:
|
|
40
|
+
if isinstance(v, Styled):
|
|
41
|
+
return _cell(ref, v.value, v.style)
|
|
42
|
+
st = f' s="{style}"' if style else ""
|
|
43
|
+
if v is None or v == "":
|
|
44
|
+
return f'<c r="{ref}"{st}/>' if style else ""
|
|
45
|
+
if isinstance(v, bool):
|
|
46
|
+
v = str(v)
|
|
47
|
+
if isinstance(v, (int, float)):
|
|
48
|
+
if v != v or v in (float("inf"), float("-inf")): # NaN / inf: leave the cell empty
|
|
49
|
+
return ""
|
|
50
|
+
return f'<c r="{ref}"{st}><v>{v}</v></c>'
|
|
51
|
+
if isinstance(v, Link):
|
|
52
|
+
f = 'HYPERLINK("%s","%s")' % (v.url.replace('"', ""), v.text.replace('"', ""))
|
|
53
|
+
return f'<c r="{ref}" s="{LINK}" t="str"><f>{escape(f)}</f><v>{escape(v.text)}</v></c>'
|
|
54
|
+
text = escape(_BAD.sub("", str(v)))
|
|
55
|
+
return f'<c r="{ref}" t="inlineStr"{st}><is><t xml:space="preserve">{text}</t></is></c>'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _sheet(rows: list[list[Any]], widths: list[float] | None, header: bool, styles: list[int] | None) -> str:
|
|
59
|
+
out = []
|
|
60
|
+
for r, row in enumerate(rows, start=1):
|
|
61
|
+
cells = []
|
|
62
|
+
for c, v in enumerate(row):
|
|
63
|
+
style = H if (header and r == 1) else ((styles[c] if styles and c < len(styles) else 0) if not isinstance(v, Styled) else 0)
|
|
64
|
+
cells.append(_cell(f"{_col(c)}{r}", v, style))
|
|
65
|
+
out.append(f'<row r="{r}">{"".join(cells)}</row>')
|
|
66
|
+
cols = "".join(f'<col min="{i + 1}" max="{i + 1}" width="{w}" customWidth="1"/>' for i, w in enumerate(widths or []))
|
|
67
|
+
ncol = max((len(r) for r in rows), default=1)
|
|
68
|
+
view = '<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>' if header else ""
|
|
69
|
+
filt = f'<autoFilter ref="A1:{_col(ncol - 1)}{len(rows)}"/>' if header and len(rows) > 1 else ""
|
|
70
|
+
return ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
71
|
+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
|
72
|
+
f'{view}{"<cols>" + cols + "</cols>" if cols else ""}<sheetData>{"".join(out)}</sheetData>{filt}</worksheet>')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
STYLES = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
76
|
+
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
|
77
|
+
'<numFmts count="2"><numFmt numFmtId="164" formatCode="0.00"/><numFmt numFmtId="165" formatCode="0.000"/></numFmts>'
|
|
78
|
+
'<fonts count="3"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font>'
|
|
79
|
+
'<font><u/><color rgb="FF0563C1"/><sz val="11"/><name val="Calibri"/></font></fonts>'
|
|
80
|
+
'<fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill>'
|
|
81
|
+
'<fill><patternFill patternType="solid"><fgColor rgb="FFDCE6F2"/><bgColor indexed="64"/></patternFill></fill></fills>'
|
|
82
|
+
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
|
|
83
|
+
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
|
84
|
+
'<cellXfs count="6"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
|
|
85
|
+
'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
|
|
86
|
+
'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
|
|
87
|
+
'<xf numFmtId="165" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
|
|
88
|
+
'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
|
|
89
|
+
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment wrapText="1" vertical="top"/></xf></cellXfs>'
|
|
90
|
+
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>')
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def workbook_bytes(sheets: Iterable[dict[str, Any]]) -> bytes:
|
|
94
|
+
"""``sheets``: dicts with name, rows (list of lists), optional widths, header (bool), styles (per column)."""
|
|
95
|
+
sheets = list(sheets)
|
|
96
|
+
buf = io.BytesIO()
|
|
97
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
98
|
+
over = "".join(f'<Override PartName="/xl/worksheets/sheet{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
|
99
|
+
for i in range(1, len(sheets) + 1))
|
|
100
|
+
z.writestr("[Content_Types].xml",
|
|
101
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|
102
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>'
|
|
103
|
+
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
|
104
|
+
f'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>{over}</Types>')
|
|
105
|
+
z.writestr("_rels/.rels", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
106
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>')
|
|
107
|
+
clean = [escape(_SHEET_BAD.sub(" ", s["name"])[:31]) for s in sheets] # outside the f-string: Python 3.11 allows no backslash there
|
|
108
|
+
names = "".join(f'<sheet name="{n}" sheetId="{i}" r:id="rId{i}"/>' for i, n in enumerate(clean, start=1))
|
|
109
|
+
z.writestr("xl/workbook.xml", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
|
110
|
+
f'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>{names}</sheets></workbook>')
|
|
111
|
+
rels = "".join(f'<Relationship Id="rId{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{i}.xml"/>'
|
|
112
|
+
for i in range(1, len(sheets) + 1))
|
|
113
|
+
rels += f'<Relationship Id="rId{len(sheets) + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
|
114
|
+
z.writestr("xl/_rels/workbook.xml.rels", f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>')
|
|
115
|
+
z.writestr("xl/styles.xml", STYLES)
|
|
116
|
+
for i, s in enumerate(sheets, start=1):
|
|
117
|
+
z.writestr(f"xl/worksheets/sheet{i}.xml", _sheet(s["rows"], s.get("widths"), s.get("header", True), s.get("styles")))
|
|
118
|
+
return buf.getvalue()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
TXT = {
|
|
122
|
+
"en": {"summary": "Summary", "changes": "Changes", "scenes": "Scenes", "item": "Item", "value": "Value",
|
|
123
|
+
"name": "Name", "before": "Before (baseline)", "after": "After", "area": "Area (km²)", "res": "Pixel size (m)",
|
|
124
|
+
"comparable": "Comparable share of the area (%)", "scenes_n": "Scenes used (before + after)", "mmu": "Smallest change mapped (ha)",
|
|
125
|
+
"sens": "Sensitivity", "total": "Total", "polys": "polygons", "cause": "Vegetation loss, likely cause (a hint)", "warn": "Warning",
|
|
126
|
+
"note": "Note", "note_v": "Automated screening from free Sentinel-2 imagery: verify important findings with higher-resolution data or in the field.",
|
|
127
|
+
"data": "Data", "made": "Made with", "cols": ["#", "What changed", "Description", "Area (ha)", "Strength", "Confidence (0-1)", "Likely cause", "Seasonal",
|
|
128
|
+
"NDVI before", "NDVI after", "MNDWI before", "MNDWI after", "BSI before", "BSI after", "dNBR", "When (largest drop)", "Latitude", "Longitude", "Map"],
|
|
129
|
+
"scols": ["Period", "Scene", "Date", "Scene cloud (%)", "Cloud-free share of the area (%)"], "yes": "yes", "map": "open map"},
|
|
130
|
+
"tr": {"summary": "Özet", "changes": "Değişimler", "scenes": "Sahneler", "item": "Konu", "value": "Değer",
|
|
131
|
+
"name": "Ad", "before": "Önce (karşılaştırma)", "after": "Sonra", "area": "Alan (km²)", "res": "Piksel boyutu (m)",
|
|
132
|
+
"comparable": "Alanın karşılaştırılabilir payı (%)", "scenes_n": "Kullanılan sahneler (önce + sonra)", "mmu": "Haritalanan en küçük değişim (ha)",
|
|
133
|
+
"sens": "Duyarlılık", "total": "Toplam", "polys": "poligon", "cause": "Bitki kaybı, olası neden (ipucu)", "warn": "Uyarı",
|
|
134
|
+
"note": "Not", "note_v": "Ücretsiz Sentinel-2 görüntüleriyle otomatik tarama: önemli bulguları daha yüksek çözünürlüklü veriyle ya da arazide doğrulayın.",
|
|
135
|
+
"data": "Veri", "made": "Üreten", "cols": ["#", "Ne değişti", "Açıklama", "Alan (ha)", "Güç", "Güven (0-1)", "Olası neden", "Mevsimsel",
|
|
136
|
+
"NDVI önce", "NDVI sonra", "MNDWI önce", "MNDWI sonra", "BSI önce", "BSI sonra", "dNBR", "Ne zaman (en büyük düşüş)", "Enlem", "Boylam", "Harita"],
|
|
137
|
+
"scols": ["Dönem", "Sahne", "Tarih", "Sahne bulut (%)", "Alanın bulutsuz payı (%)"], "yes": "evet", "map": "haritada aç"},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def report_workbook(lang: str, meta: dict, totals: dict, warnings: list[str], geojson: dict, scenes: list[dict],
|
|
142
|
+
extras: dict | None, attribution: str, terrain_attribution: str | None = None) -> bytes:
|
|
143
|
+
lang = "tr" if lang == "tr" else "en"
|
|
144
|
+
t = TXT[lang]
|
|
145
|
+
summary: list[list[Any]] = [[t["item"], t["value"]],
|
|
146
|
+
[t["name"], meta.get("name")], [t["before"], meta.get("before_label")], [t["after"], meta.get("after_label")],
|
|
147
|
+
[t["area"], Styled(meta.get("aoi_km2"), NUM2)], [t["res"], meta.get("resolution_m")],
|
|
148
|
+
[t["comparable"], Styled(round(100 * float(meta.get("valid_fraction", 0)), 1), NUM2)],
|
|
149
|
+
[t["scenes_n"], f'{meta.get("scenes_before")} + {meta.get("scenes_after")}'],
|
|
150
|
+
[t["mmu"], meta.get("mmu_ha")], [t["sens"], meta.get("sensitivity")]]
|
|
151
|
+
for key, tt in totals.items():
|
|
152
|
+
summary.append([f'{t["total"]}: {class_label(key, lang)} (ha)', Styled(round(float(tt.get("area_ha", 0)), 2), NUM2)])
|
|
153
|
+
summary.append([f'{t["total"]}: {class_label(key, lang)} ({t["polys"]})', int(tt.get("polygons", 0))])
|
|
154
|
+
cause = (extras or {}).get("cause")
|
|
155
|
+
if cause:
|
|
156
|
+
for k, v in cause.get("totals", {}).items():
|
|
157
|
+
summary.append([f'{t["cause"]}: {CAUSE_SHORT[lang].get(k, k)} (ha)', Styled(round(float(v.get("area_ha", 0)), 2), NUM2)])
|
|
158
|
+
for w in warnings:
|
|
159
|
+
summary.append([t["warn"], Styled(w, WRAP)])
|
|
160
|
+
summary.append([t["note"], Styled(t["note_v"], WRAP)])
|
|
161
|
+
summary.append([t["data"], Styled(attribution + ("; " + terrain_attribution if terrain_attribution else ""), WRAP)])
|
|
162
|
+
summary.append([t["made"], f'earthdiff {meta.get("version")} ({meta.get("generated")})'])
|
|
163
|
+
|
|
164
|
+
feats = sorted(geojson.get("features", []), key=lambda f: -float(f["properties"].get("area_ha", 0)))
|
|
165
|
+
changes: list[list[Any]] = [t["cols"]]
|
|
166
|
+
for f in feats:
|
|
167
|
+
p = f["properties"]
|
|
168
|
+
lat, lon = p.get("centroid_lat"), p.get("centroid_lon")
|
|
169
|
+
url = f"https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=16/{lat}/{lon}"
|
|
170
|
+
changes.append([p.get("id"), class_label(p.get("class", ""), lang), describe(p, lang), p.get("area_ha"),
|
|
171
|
+
STRENGTH[lang].get(p.get("confidence_level"), p.get("confidence_level")), p.get("confidence"),
|
|
172
|
+
CAUSE_SHORT[lang].get(p.get("likely_cause"), p.get("likely_cause") or ""), t["yes"] if p.get("likely_seasonal") is True else "",
|
|
173
|
+
p.get("ndvi_before"), p.get("ndvi_after"), p.get("mndwi_before"), p.get("mndwi_after"), p.get("bsi_before"), p.get("bsi_after"),
|
|
174
|
+
p.get("dnbr"), (f'{p["event_before"]} - {p["event_after"]}' if p.get("event_after") else ""), lat, lon, Link(url, t["map"])])
|
|
175
|
+
sc_rows: list[list[Any]] = [t["scols"]]
|
|
176
|
+
for s in scenes:
|
|
177
|
+
sc_rows.append([s.get("period"), s.get("id"), str(s.get("date", ""))[:10], s.get("cloud_cover"), round(100 * float(s.get("valid_fraction", 0)), 1)])
|
|
178
|
+
return workbook_bytes([
|
|
179
|
+
{"name": t["summary"], "rows": summary, "widths": [44, 90], "header": True},
|
|
180
|
+
{"name": t["changes"], "rows": changes, "widths": [6, 22, 70, 10, 10, 10, 14, 10, 10, 10, 11, 11, 10, 10, 8, 24, 11, 11, 13],
|
|
181
|
+
"styles": [0, WRAP, WRAP, NUM2, 0, NUM2, 0, 0, NUM3, NUM3, NUM3, NUM3, NUM3, NUM3, NUM3, 0, 0, 0, 0]},
|
|
182
|
+
{"name": t["scenes"], "rows": sc_rows, "widths": [34, 44, 12, 14, 26]},
|
|
183
|
+
])
|
earthdiff/yearly.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""In-between years for the report timeline.
|
|
2
|
+
|
|
3
|
+
When the before and after windows are years apart, the timeline would jump straight from one to the other. For every
|
|
4
|
+
year in between, this module takes the same season as the after window, finds the clearest Sentinel-2 pass and reads
|
|
5
|
+
only a small true-colour frame (the same size as the other timeline frames, from the COG overviews). These frames are
|
|
6
|
+
for looking only: they do not change the analysis, which still compares the before and after composites."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from datetime import date, timedelta
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from affine import Affine
|
|
15
|
+
|
|
16
|
+
from .aoi import Grid
|
|
17
|
+
from .composite import to_reflectance, valid_from_scl
|
|
18
|
+
from .periods import Period
|
|
19
|
+
from .render import to_mercator, true_color, webp_bytes
|
|
20
|
+
from .report import data_url
|
|
21
|
+
|
|
22
|
+
MAX_BETWEEN_YEARS = 10 # at most this many in-between frames (spread evenly when the gap is longer)
|
|
23
|
+
CANDIDATES_PER_YEAR = 3 # passes checked per year (least cloudy first), the clearest one is used
|
|
24
|
+
MIN_CLEAR = 0.5 # a pass must show at least half of the area cloud-free to be used
|
|
25
|
+
WIDEN_DAYS = 31 # a year with no clear pass is retried with its window widened by this much on each side
|
|
26
|
+
FIRST_DATA = date(2016, 11, 1) # Sentinel-2 L2A COGs start around then
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def between_periods(before: Period, after: Period) -> list[Period]:
|
|
30
|
+
"""The after window shifted back by 1, 2, ... years, as long as it lies strictly between the two windows."""
|
|
31
|
+
out: list[Period] = []
|
|
32
|
+
k = 1
|
|
33
|
+
while k < 60:
|
|
34
|
+
p = after.shift_years(-k)
|
|
35
|
+
if p.end < before.end or p.start <= before.end:
|
|
36
|
+
break
|
|
37
|
+
if p.end < after.start:
|
|
38
|
+
out.append(p)
|
|
39
|
+
k += 1
|
|
40
|
+
out.reverse()
|
|
41
|
+
out = [p for p in out if p.end >= FIRST_DATA] # Sentinel-2 L2A COGs start in 2016-2017: earlier years would only cost searches
|
|
42
|
+
if len(out) > MAX_BETWEEN_YEARS:
|
|
43
|
+
idx = sorted({round(i * (len(out) - 1) / (MAX_BETWEEN_YEARS - 1)) for i in range(MAX_BETWEEN_YEARS)})
|
|
44
|
+
out = [out[i] for i in idx]
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def coarse_grid(grid: Grid, step: int) -> Grid:
|
|
49
|
+
return Grid(grid.epsg, grid.transform * Affine.scale(step), math.ceil(grid.width / step), math.ceil(grid.height / step), grid.res * step)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def between_frames(geom, grid: Grid, mask: np.ndarray, before: Period, after: Period, backend: str, max_cloud: float,
|
|
53
|
+
step: int, search, reader, say: Callable[[str], None] | None = None, with_nbr: bool = True) -> dict[str, Any]:
|
|
54
|
+
"""Frames for the timeline and, with ``with_nbr``, a series point per year (NBR, NDVI, brightness) for ``history.py``."""
|
|
55
|
+
say = say or (lambda m: None)
|
|
56
|
+
periods = between_periods(before, after)
|
|
57
|
+
if not periods:
|
|
58
|
+
return {"frames": [], "series": []}
|
|
59
|
+
g = coarse_grid(grid, step)
|
|
60
|
+
sub = mask[::step, ::step]
|
|
61
|
+
n = max(1, int(sub.sum()))
|
|
62
|
+
say(f"in-between years: one small frame for each of {len(periods)} year(s) ({periods[0].start.year}-{periods[-1].start.year})")
|
|
63
|
+
frames: list[dict[str, Any]] = []
|
|
64
|
+
series: list[dict[str, Any]] = []
|
|
65
|
+
def pick(p: Period, k: int):
|
|
66
|
+
try:
|
|
67
|
+
cands = sorted(search(backend, geom, p, max_cloud), key=lambda s: s.cloud_cover)[:k]
|
|
68
|
+
except Exception: # a failed search only costs one frame
|
|
69
|
+
return None
|
|
70
|
+
best = None
|
|
71
|
+
for sc in cands:
|
|
72
|
+
try:
|
|
73
|
+
valid = valid_from_scl(reader(sc, "scl", g), g.res) & sub
|
|
74
|
+
except Exception:
|
|
75
|
+
continue
|
|
76
|
+
share = float(valid.sum()) / n
|
|
77
|
+
if share >= MIN_CLEAR and (best is None or share > best[1]):
|
|
78
|
+
best = (sc, share, valid)
|
|
79
|
+
return best
|
|
80
|
+
|
|
81
|
+
for p in periods:
|
|
82
|
+
best = pick(p, CANDIDATES_PER_YEAR)
|
|
83
|
+
if best is None:
|
|
84
|
+
# nothing clear enough (cloud, smoke of the very fire we are after...): widen the window by a month on each side, inside the gap
|
|
85
|
+
wide = Period(max(p.start - timedelta(days=WIDEN_DAYS), before.end + timedelta(days=1)), min(p.end + timedelta(days=WIDEN_DAYS), after.start - timedelta(days=1)))
|
|
86
|
+
if wide.start <= wide.end and (wide.start, wide.end) != (p.start, p.end):
|
|
87
|
+
best = pick(wide, CANDIDATES_PER_YEAR + 3)
|
|
88
|
+
if best is None:
|
|
89
|
+
continue
|
|
90
|
+
sc, share, valid = best
|
|
91
|
+
try:
|
|
92
|
+
bands = {}
|
|
93
|
+
for b in ("red", "green", "blue") + (("nir", "swir22") if with_nbr else ()):
|
|
94
|
+
dn = reader(sc, b, g)
|
|
95
|
+
scale, offset = sc.radiometry.get(b, (1e-4, 0.0))
|
|
96
|
+
refl = to_reflectance(dn, scale, offset)
|
|
97
|
+
refl[(dn == 0) | ~valid] = np.nan
|
|
98
|
+
bands[b] = refl
|
|
99
|
+
except Exception:
|
|
100
|
+
if not all(b in bands for b in ("red", "green", "blue")):
|
|
101
|
+
continue
|
|
102
|
+
img, _ = to_mercator(true_color(bands, sub), g, max_dim=None)
|
|
103
|
+
if with_nbr and "nir" in bands and "swir22" in bands:
|
|
104
|
+
from .history import point
|
|
105
|
+
|
|
106
|
+
series.append(point("between", sc.date[:10], bands))
|
|
107
|
+
frames.append({"period": "between", "date": sc.date[:10], "scene": sc.id, "clear_fraction": round(share, 3),
|
|
108
|
+
"img": data_url(webp_bytes(img, quality=80), "image/webp")})
|
|
109
|
+
return {"frames": frames, "series": series}
|