181 lines
6.7 KiB
Python
Executable File
181 lines
6.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Downloads the 1 x 1 degree cells of Earth's surface at 10 m that scenes name, and
|
|
makes the renderer's fine regions from them.
|
|
|
|
scripts/fetch-earth-regions.py [--scene FILE] [--box S W N E] [CELL ...]
|
|
|
|
Cells come from each scene's `[appearance] earth_regions` boxes, from each --box
|
|
(south, west, north, east in degrees; west east of east crosses the 180 degree
|
|
meridian), and from names such as N29E032 (29 to 30 N, 32 to 33 E). Only those cells
|
|
are fetched; nothing else is, and the renderer never fetches anything itself.
|
|
|
|
Source: ESA WorldCover 10 m 2021 v200, Sentinel-2 RGBNIR composite: the median of the
|
|
year's cloud-free Sentinel-2 L2A surface reflectance in bands B04, B03, B02 and B08,
|
|
as 12000 x 12000 GeoTIFFs of 16-bit values (1e-4 reflectance per unit, 0 where there
|
|
is no data), one per cell with land in it, from the public bucket
|
|
s3://esa-worldcover-s2 (Zanaga et al. 2022, doi:10.5281/zenodo.7254221). License:
|
|
CC BY 4.0; credit "ESA WorldCover project 2021 / Contains modified Copernicus Sentinel
|
|
data (2021) processed by ESA WorldCover consortium".
|
|
|
|
Each file (about 650 MB) goes to data/earth/regions/source/ and is checked against
|
|
the bucket's ETag, the MD5 of its 8 MiB parts. A cell the bucket has no file for is
|
|
all water, and gets an empty data/earth/regions/<cell>.none so the renderer does not
|
|
ask for it again. Then `resolved earth-regions` makes data/earth/regions/<cell>.tiles
|
|
(194 MB of BC6H, about 10 s a cell; it builds the CLI first), and remakes cells made
|
|
before whose neighbors have arrived since, so their borders join up and their holes
|
|
are filled alike.
|
|
|
|
Needs only the Python standard library.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import math
|
|
import subprocess
|
|
import sys
|
|
import tomllib
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
BUCKET = "https://esa-worldcover-s2.s3.eu-central-1.amazonaws.com/rgbnir/2021"
|
|
PART = 8 * 1024 * 1024 # the bucket's multipart upload part size
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
OUT = ROOT / "data" / "earth" / "regions"
|
|
SOURCE = OUT / "source"
|
|
|
|
|
|
def name(south, west):
|
|
"""The cell's name, as N29E032 (as Cell::name in earth.rs)."""
|
|
ns = "S" if south < 0 else "N"
|
|
ew = "W" if west < 0 else "E"
|
|
return f"{ns}{abs(south):02}{ew}{abs(west):03}"
|
|
|
|
|
|
def parse(cell):
|
|
"""South and west of a name such as N29E032."""
|
|
ok = len(cell) == 7 and cell[0] in "NS" and cell[3] in "EW"
|
|
ok = ok and cell[1:3].isdigit() and cell[4:].isdigit()
|
|
if not ok:
|
|
sys.exit(f"{cell!r} does not name a cell, as N29E032 does")
|
|
south = int(cell[1:3]) * (-1 if cell[0] == "S" else 1)
|
|
west = int(cell[4:]) * (-1 if cell[3] == "W" else 1)
|
|
if not (-90 <= south < 90 and -180 <= west < 180):
|
|
sys.exit(f"{cell} is not on Earth")
|
|
return south, west
|
|
|
|
|
|
def covering(south, west, north, east):
|
|
"""The cells over a box, as Cell::covering in earth.rs."""
|
|
valid = -90 <= south < north <= 90 and -180 <= west <= 180
|
|
valid = valid and -180 <= east <= 180 and west != east
|
|
if not valid:
|
|
sys.exit(f"{south}, {west} to {north}, {east} is not a box of latitude and longitude")
|
|
first, last = math.floor(west), math.ceil(east)
|
|
if last <= first:
|
|
last += 360
|
|
cells = set()
|
|
for s in range(max(math.floor(south), -90), min(math.ceil(north), 90)):
|
|
for w in range(first, last):
|
|
cells.add((s, (w + 180) % 360 - 180))
|
|
return cells
|
|
|
|
|
|
def file_name(cell):
|
|
return f"ESA_WorldCover_10m_2021_v200_{cell}_S2RGBNIR.tif"
|
|
|
|
|
|
def url(cell):
|
|
return f"{BUCKET}/{cell[:3]}/{file_name(cell)}"
|
|
|
|
|
|
def etag(cell):
|
|
"""The bucket's ETag for the cell, or None if it has no file for it."""
|
|
request = urllib.request.Request(url(cell), method="HEAD")
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=60) as response:
|
|
return response.headers["ETag"].strip('"')
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 404:
|
|
return None
|
|
raise
|
|
|
|
|
|
def local_etag(path, parts):
|
|
"""The ETag S3 gives a file uploaded in `parts` parts of PART bytes, or whole."""
|
|
if parts == 0:
|
|
return hashlib.md5(path.read_bytes()).hexdigest()
|
|
digests = []
|
|
with open(path, "rb") as f:
|
|
while chunk := f.read(PART):
|
|
digests.append(hashlib.md5(chunk).digest())
|
|
return f"{hashlib.md5(b''.join(digests)).hexdigest()}-{len(digests)}"
|
|
|
|
|
|
def matches(path, tag):
|
|
parts = int(tag.split("-")[1]) if "-" in tag else 0
|
|
return local_etag(path, parts) == tag
|
|
|
|
|
|
def fetch(cell):
|
|
"""Downloads the cell's file if it is not here and checked; False if the bucket
|
|
has none."""
|
|
tag = etag(cell)
|
|
if tag is None:
|
|
(OUT / f"{cell}.none").touch()
|
|
print(f"{cell}: all water, no file")
|
|
return False
|
|
path = SOURCE / file_name(cell)
|
|
if path.is_file() and matches(path, tag):
|
|
return True
|
|
print(f"{cell}: fetching")
|
|
part = path.with_suffix(".part")
|
|
with urllib.request.urlopen(url(cell), timeout=60) as response, open(part, "wb") as f:
|
|
while chunk := response.read(1 << 20):
|
|
f.write(chunk)
|
|
if not matches(part, tag):
|
|
part.unlink()
|
|
sys.exit(f"{cell}: download does not match the bucket's ETag {tag}")
|
|
part.rename(path)
|
|
return True
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
parser.add_argument("--scene", action="append", default=[], type=Path)
|
|
parser.add_argument("--box", action="append", default=[], nargs=4, type=float)
|
|
parser.add_argument("cells", nargs="*")
|
|
args = parser.parse_args()
|
|
|
|
wanted = {parse(c) for c in args.cells}
|
|
for box in args.box:
|
|
wanted |= covering(*box)
|
|
for scene in args.scene:
|
|
with open(scene, "rb") as f:
|
|
boxes = tomllib.load(f).get("appearance", {}).get("earth_regions", [])
|
|
if not boxes:
|
|
print(f"{scene} names no earth_regions")
|
|
for box in boxes:
|
|
wanted |= covering(*box)
|
|
if not wanted:
|
|
sys.exit("no cells named: give --scene, --box or cell names")
|
|
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
SOURCE.mkdir(parents=True, exist_ok=True)
|
|
names = sorted(name(s, w) for s, w in wanted)
|
|
fetched = [c for c in names if not (OUT / f"{c}.none").is_file() and fetch(c)]
|
|
# Cells made before are passed too: those next to new ones are remade.
|
|
made = {p.stem for p in OUT.glob("*.tiles")}
|
|
build = sorted(set(fetched) | made)
|
|
if build:
|
|
subprocess.run(
|
|
["cargo", "run", "--quiet", "--release", "-p", "resolved-cli", "--",
|
|
"earth-regions", "--source", str(SOURCE), "--out", str(OUT), *build],
|
|
cwd=ROOT, check=True,
|
|
)
|
|
print(f"{len(fetched)} cells in {OUT}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|