mapchete-eo 2025.7.0__py2.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.
- mapchete_eo/__init__.py +1 -0
- mapchete_eo/archives/__init__.py +0 -0
- mapchete_eo/archives/base.py +65 -0
- mapchete_eo/array/__init__.py +0 -0
- mapchete_eo/array/buffer.py +16 -0
- mapchete_eo/array/color.py +29 -0
- mapchete_eo/array/convert.py +157 -0
- mapchete_eo/base.py +528 -0
- mapchete_eo/blacklist.txt +175 -0
- mapchete_eo/cli/__init__.py +30 -0
- mapchete_eo/cli/bounds.py +22 -0
- mapchete_eo/cli/options_arguments.py +243 -0
- mapchete_eo/cli/s2_brdf.py +77 -0
- mapchete_eo/cli/s2_cat_results.py +146 -0
- mapchete_eo/cli/s2_find_broken_products.py +93 -0
- mapchete_eo/cli/s2_jp2_static_catalog.py +166 -0
- mapchete_eo/cli/s2_mask.py +71 -0
- mapchete_eo/cli/s2_mgrs.py +45 -0
- mapchete_eo/cli/s2_rgb.py +114 -0
- mapchete_eo/cli/s2_verify.py +129 -0
- mapchete_eo/cli/static_catalog.py +123 -0
- mapchete_eo/eostac.py +30 -0
- mapchete_eo/exceptions.py +87 -0
- mapchete_eo/geometry.py +271 -0
- mapchete_eo/image_operations/__init__.py +12 -0
- mapchete_eo/image_operations/color_correction.py +136 -0
- mapchete_eo/image_operations/compositing.py +247 -0
- mapchete_eo/image_operations/dtype_scale.py +43 -0
- mapchete_eo/image_operations/fillnodata.py +130 -0
- mapchete_eo/image_operations/filters.py +319 -0
- mapchete_eo/image_operations/linear_normalization.py +81 -0
- mapchete_eo/image_operations/sigmoidal.py +114 -0
- mapchete_eo/io/__init__.py +37 -0
- mapchete_eo/io/assets.py +492 -0
- mapchete_eo/io/items.py +147 -0
- mapchete_eo/io/levelled_cubes.py +228 -0
- mapchete_eo/io/path.py +144 -0
- mapchete_eo/io/products.py +413 -0
- mapchete_eo/io/profiles.py +45 -0
- mapchete_eo/known_catalogs.py +42 -0
- mapchete_eo/platforms/sentinel2/__init__.py +17 -0
- mapchete_eo/platforms/sentinel2/archives.py +190 -0
- mapchete_eo/platforms/sentinel2/bandpass_adjustment.py +104 -0
- mapchete_eo/platforms/sentinel2/brdf/__init__.py +8 -0
- mapchete_eo/platforms/sentinel2/brdf/config.py +32 -0
- mapchete_eo/platforms/sentinel2/brdf/correction.py +260 -0
- mapchete_eo/platforms/sentinel2/brdf/hls.py +251 -0
- mapchete_eo/platforms/sentinel2/brdf/models.py +44 -0
- mapchete_eo/platforms/sentinel2/brdf/protocols.py +27 -0
- mapchete_eo/platforms/sentinel2/brdf/ross_thick.py +136 -0
- mapchete_eo/platforms/sentinel2/brdf/sun_angle_arrays.py +76 -0
- mapchete_eo/platforms/sentinel2/config.py +181 -0
- mapchete_eo/platforms/sentinel2/driver.py +78 -0
- mapchete_eo/platforms/sentinel2/masks.py +325 -0
- mapchete_eo/platforms/sentinel2/metadata_parser.py +734 -0
- mapchete_eo/platforms/sentinel2/path_mappers/__init__.py +29 -0
- mapchete_eo/platforms/sentinel2/path_mappers/base.py +56 -0
- mapchete_eo/platforms/sentinel2/path_mappers/earthsearch.py +34 -0
- mapchete_eo/platforms/sentinel2/path_mappers/metadata_xml.py +135 -0
- mapchete_eo/platforms/sentinel2/path_mappers/sinergise.py +105 -0
- mapchete_eo/platforms/sentinel2/preprocessing_tasks.py +26 -0
- mapchete_eo/platforms/sentinel2/processing_baseline.py +160 -0
- mapchete_eo/platforms/sentinel2/product.py +669 -0
- mapchete_eo/platforms/sentinel2/types.py +109 -0
- mapchete_eo/processes/__init__.py +0 -0
- mapchete_eo/processes/config.py +51 -0
- mapchete_eo/processes/dtype_scale.py +112 -0
- mapchete_eo/processes/eo_to_xarray.py +19 -0
- mapchete_eo/processes/merge_rasters.py +235 -0
- mapchete_eo/product.py +278 -0
- mapchete_eo/protocols.py +56 -0
- mapchete_eo/search/__init__.py +14 -0
- mapchete_eo/search/base.py +222 -0
- mapchete_eo/search/config.py +42 -0
- mapchete_eo/search/s2_mgrs.py +314 -0
- mapchete_eo/search/stac_search.py +251 -0
- mapchete_eo/search/stac_static.py +236 -0
- mapchete_eo/search/utm_search.py +251 -0
- mapchete_eo/settings.py +24 -0
- mapchete_eo/sort.py +48 -0
- mapchete_eo/time.py +53 -0
- mapchete_eo/types.py +73 -0
- mapchete_eo-2025.7.0.dist-info/METADATA +38 -0
- mapchete_eo-2025.7.0.dist-info/RECORD +87 -0
- mapchete_eo-2025.7.0.dist-info/WHEEL +5 -0
- mapchete_eo-2025.7.0.dist-info/entry_points.txt +11 -0
- mapchete_eo-2025.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pystac
|
|
8
|
+
from mapchete.cli.options import opt_debug
|
|
9
|
+
from mapchete.io import copy
|
|
10
|
+
from mapchete.io.raster import read_raster_no_crs
|
|
11
|
+
from mapchete.path import MPath
|
|
12
|
+
from tqdm import tqdm
|
|
13
|
+
|
|
14
|
+
from mapchete_eo.array.color import outlier_pixels
|
|
15
|
+
from mapchete_eo.cli import options_arguments
|
|
16
|
+
from mapchete_eo.exceptions import AssetKeyError
|
|
17
|
+
from mapchete_eo.platforms.sentinel2.product import asset_mpath
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Report:
|
|
24
|
+
item: pystac.Item
|
|
25
|
+
missing_asset_entries: List[str]
|
|
26
|
+
missing_assets: List[MPath]
|
|
27
|
+
color_artefacts: bool = False
|
|
28
|
+
|
|
29
|
+
def product_broken(self) -> bool:
|
|
30
|
+
return any(
|
|
31
|
+
[
|
|
32
|
+
bool(self.missing_asset_entries),
|
|
33
|
+
bool(self.missing_assets),
|
|
34
|
+
bool(self.color_artefacts),
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.command()
|
|
40
|
+
@options_arguments.arg_stac_items
|
|
41
|
+
@options_arguments.opt_assets
|
|
42
|
+
@opt_debug
|
|
43
|
+
def s2_verify(
|
|
44
|
+
stac_items: List[MPath],
|
|
45
|
+
assets: List[str] = [],
|
|
46
|
+
asset_exists_check: bool = True,
|
|
47
|
+
**_,
|
|
48
|
+
):
|
|
49
|
+
"""Verify Sentinel-2 products."""
|
|
50
|
+
assets = assets or []
|
|
51
|
+
for item_path in tqdm(stac_items):
|
|
52
|
+
report = verify_item(
|
|
53
|
+
pystac.Item.from_file(item_path),
|
|
54
|
+
assets=assets,
|
|
55
|
+
asset_exists_check=asset_exists_check,
|
|
56
|
+
)
|
|
57
|
+
for asset in report.missing_asset_entries:
|
|
58
|
+
tqdm.write(f"[ERROR] {report.item.id} has no asset named '{asset}")
|
|
59
|
+
for path in report.missing_assets:
|
|
60
|
+
tqdm.write(
|
|
61
|
+
f"[ERROR] {report.item.id} asset '{asset}' with path {str(path)} does not exist"
|
|
62
|
+
)
|
|
63
|
+
if report.color_artefacts:
|
|
64
|
+
tqdm.write(
|
|
65
|
+
f"[ERROR] {report.item.id} thumbnail ({report.item.assets['thumbnail'].href}) indicates that there are some color artefacts"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def verify_item(
|
|
70
|
+
item: pystac.Item,
|
|
71
|
+
assets: List[str],
|
|
72
|
+
asset_exists_check: bool = False,
|
|
73
|
+
check_thumbnail: bool = True,
|
|
74
|
+
thumbnail_dir: Optional[MPath] = None,
|
|
75
|
+
):
|
|
76
|
+
missing_asset_entries = []
|
|
77
|
+
missing_assets = []
|
|
78
|
+
color_artefacts = False
|
|
79
|
+
for asset in assets:
|
|
80
|
+
logger.debug("verify asset %s is available", asset)
|
|
81
|
+
if asset not in item.assets:
|
|
82
|
+
missing_asset_entries.append(asset)
|
|
83
|
+
if asset_exists_check:
|
|
84
|
+
try:
|
|
85
|
+
path = asset_mpath(item=item, asset=asset)
|
|
86
|
+
logger.debug("check if asset %s (%s) exists", asset, str(path))
|
|
87
|
+
if not path.exists():
|
|
88
|
+
missing_assets.append(path)
|
|
89
|
+
except AssetKeyError:
|
|
90
|
+
missing_asset_entries.append(asset)
|
|
91
|
+
if check_thumbnail:
|
|
92
|
+
thumbnail_href = MPath.from_inp(item.assets["thumbnail"].href)
|
|
93
|
+
logger.debug("check thumbnail %s for artefacts ...", thumbnail_href)
|
|
94
|
+
if thumbnail_dir:
|
|
95
|
+
thumbnail_path = thumbnail_dir / item.id + ".jpg"
|
|
96
|
+
copy(thumbnail_href, thumbnail_path)
|
|
97
|
+
else:
|
|
98
|
+
thumbnail_path = thumbnail_href
|
|
99
|
+
color_artefacts = outlier_pixels_detected(read_raster_no_crs(thumbnail_href))
|
|
100
|
+
return Report(
|
|
101
|
+
item,
|
|
102
|
+
missing_asset_entries=missing_asset_entries,
|
|
103
|
+
missing_assets=missing_assets,
|
|
104
|
+
color_artefacts=color_artefacts,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def outlier_pixels_detected(
|
|
109
|
+
arr: np.ndarray,
|
|
110
|
+
axis: int = 0,
|
|
111
|
+
range_threshold: int = 100,
|
|
112
|
+
allowed_error_percentage: float = 1,
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Checks whether number of outlier pixels is larger than allowed.
|
|
116
|
+
|
|
117
|
+
An outlier pixel is a pixel, where the value range between bands exceeds
|
|
118
|
+
the range_threshold.
|
|
119
|
+
"""
|
|
120
|
+
_, width, height = arr.shape
|
|
121
|
+
pixels = width * height
|
|
122
|
+
outliers = outlier_pixels(arr, axis=axis, range_threshold=range_threshold).sum()
|
|
123
|
+
outlier_percent = outliers / pixels * 100
|
|
124
|
+
logger.debug(
|
|
125
|
+
"%s (%s %%) suspicious pixels detected",
|
|
126
|
+
outliers,
|
|
127
|
+
round(outlier_percent, 2),
|
|
128
|
+
)
|
|
129
|
+
return outlier_percent > allowed_error_percentage
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from mapchete.cli.options import opt_bounds, opt_debug
|
|
6
|
+
from mapchete.path import MPath
|
|
7
|
+
from mapchete.types import Bounds
|
|
8
|
+
from rasterio.profiles import Profile
|
|
9
|
+
|
|
10
|
+
from mapchete_eo.cli import options_arguments
|
|
11
|
+
from mapchete_eo.platforms.sentinel2 import S2Metadata
|
|
12
|
+
from mapchete_eo.platforms.sentinel2.archives import KnownArchives
|
|
13
|
+
from mapchete_eo.platforms.sentinel2.types import Resolution
|
|
14
|
+
from mapchete_eo.search import STACSearchCatalog, STACStaticCatalog
|
|
15
|
+
from mapchete_eo.search.base import CatalogSearcher
|
|
16
|
+
from mapchete_eo.types import TimeRange
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command()
|
|
20
|
+
@options_arguments.arg_dst_path
|
|
21
|
+
@opt_bounds
|
|
22
|
+
@options_arguments.opt_mgrs_tile
|
|
23
|
+
@options_arguments.opt_start_time
|
|
24
|
+
@options_arguments.opt_end_time
|
|
25
|
+
@options_arguments.opt_archive
|
|
26
|
+
@options_arguments.opt_collection
|
|
27
|
+
@options_arguments.opt_endpoint
|
|
28
|
+
@options_arguments.opt_catalog_json
|
|
29
|
+
@options_arguments.opt_name
|
|
30
|
+
@options_arguments.opt_description
|
|
31
|
+
@options_arguments.opt_assets
|
|
32
|
+
@options_arguments.opt_assets_dst_resolution
|
|
33
|
+
@options_arguments.opt_assets_dst_rio_profile
|
|
34
|
+
@options_arguments.opt_copy_metadata
|
|
35
|
+
@options_arguments.opt_overwrite
|
|
36
|
+
@opt_debug
|
|
37
|
+
def static_catalog(
|
|
38
|
+
dst_path: MPath,
|
|
39
|
+
start_time: datetime,
|
|
40
|
+
end_time: datetime,
|
|
41
|
+
bounds: Optional[Bounds] = None,
|
|
42
|
+
mgrs_tile: Optional[str] = None,
|
|
43
|
+
archive: Optional[KnownArchives] = None,
|
|
44
|
+
collection: Optional[str] = None,
|
|
45
|
+
endpoint: Optional[str] = None,
|
|
46
|
+
catalog_json: Optional[MPath] = None,
|
|
47
|
+
name: Optional[str] = None,
|
|
48
|
+
description: Optional[str] = None,
|
|
49
|
+
assets: Optional[List[str]] = None,
|
|
50
|
+
assets_dst_resolution: Resolution = Resolution.original,
|
|
51
|
+
assets_dst_rio_profile: Optional[Profile] = None,
|
|
52
|
+
copy_metadata: bool = False,
|
|
53
|
+
overwrite: bool = False,
|
|
54
|
+
**__,
|
|
55
|
+
):
|
|
56
|
+
"""Write a static STAC catalog for selected area."""
|
|
57
|
+
if catalog_json and endpoint: # pragma: no cover
|
|
58
|
+
raise click.ClickException(
|
|
59
|
+
"exactly one of --archive, --catalog-json or --endpoint has to be set."
|
|
60
|
+
)
|
|
61
|
+
if any([start_time is None, end_time is None]): # pragma: no cover
|
|
62
|
+
raise click.ClickException("--start-time and --end-time are mandatory")
|
|
63
|
+
if all([bounds is None, mgrs_tile is None]): # pragma: no cover
|
|
64
|
+
raise click.ClickException("--bounds or --mgrs-tile are required")
|
|
65
|
+
catalog = get_catalog(
|
|
66
|
+
catalog_json=catalog_json,
|
|
67
|
+
endpoint=endpoint,
|
|
68
|
+
known_archive=archive,
|
|
69
|
+
collection=collection,
|
|
70
|
+
)
|
|
71
|
+
if hasattr(catalog, "write_static_catalog"):
|
|
72
|
+
with options_arguments.TqdmUpTo(
|
|
73
|
+
unit="products", unit_scale=True, miniters=1, disable=opt_debug
|
|
74
|
+
) as progress:
|
|
75
|
+
catalog_json = catalog.write_static_catalog(
|
|
76
|
+
dst_path,
|
|
77
|
+
name=name,
|
|
78
|
+
bounds=bounds,
|
|
79
|
+
time=TimeRange(
|
|
80
|
+
start=start_time,
|
|
81
|
+
end=end_time,
|
|
82
|
+
),
|
|
83
|
+
search_kwargs=dict(mgrs_tile=mgrs_tile),
|
|
84
|
+
description=description,
|
|
85
|
+
assets=assets,
|
|
86
|
+
assets_dst_resolution=assets_dst_resolution.value,
|
|
87
|
+
assets_convert_profile=assets_dst_rio_profile,
|
|
88
|
+
copy_metadata=copy_metadata,
|
|
89
|
+
metadata_parser_classes=(S2Metadata,),
|
|
90
|
+
overwrite=overwrite,
|
|
91
|
+
progress_callback=progress.update_to,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
click.echo(f"Catalog successfully written to {catalog_json}")
|
|
95
|
+
|
|
96
|
+
else:
|
|
97
|
+
raise AttributeError(
|
|
98
|
+
f"catalog {catalog} does not support writing a static version"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_catalog(
|
|
103
|
+
catalog_json: Optional[MPath],
|
|
104
|
+
endpoint: Optional[MPath],
|
|
105
|
+
known_archive: Optional[KnownArchives] = None,
|
|
106
|
+
collection: Optional[str] = None,
|
|
107
|
+
) -> CatalogSearcher:
|
|
108
|
+
if catalog_json:
|
|
109
|
+
return STACStaticCatalog(
|
|
110
|
+
baseurl=catalog_json,
|
|
111
|
+
)
|
|
112
|
+
elif endpoint:
|
|
113
|
+
if collection:
|
|
114
|
+
return STACSearchCatalog(
|
|
115
|
+
endpoint=endpoint,
|
|
116
|
+
collections=[collection],
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
raise ValueError("collection must be provided")
|
|
120
|
+
elif known_archive:
|
|
121
|
+
return known_archive.value.catalog
|
|
122
|
+
else:
|
|
123
|
+
raise TypeError("cannot determine catalog")
|
mapchete_eo/eostac.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Driver class for EOSTAC static STAC catalogs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from mapchete_eo import base
|
|
6
|
+
|
|
7
|
+
METADATA: dict = {
|
|
8
|
+
"driver_name": "EOSTAC_DEV",
|
|
9
|
+
"data_type": None,
|
|
10
|
+
"mode": "r",
|
|
11
|
+
"file_extensions": [],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InputTile(base.EODataCube):
|
|
16
|
+
"""
|
|
17
|
+
Target Tile representation of input data.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
tile : ``Tile``
|
|
22
|
+
kwargs : keyword arguments
|
|
23
|
+
driver specific parameters
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InputData(base.InputData):
|
|
28
|
+
"""In case this driver is used when being a readonly input to another process."""
|
|
29
|
+
|
|
30
|
+
input_tile_cls = InputTile
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Custom exceptions."""
|
|
2
|
+
|
|
3
|
+
from mapchete.errors import MapcheteNodataTile
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EmptyFootprintException(Exception):
|
|
7
|
+
"""Raised when footprint is empty."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EmptySliceException(Exception):
|
|
11
|
+
"""Raised when slice is empty."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EmptyProductException(EmptySliceException):
|
|
15
|
+
"""Raised when product is empty."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EmptyStackException(MapcheteNodataTile):
|
|
19
|
+
"""Raised when whole stack is empty."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EmptyFileException(Exception):
|
|
23
|
+
"""Raised when no bytes are downloaded."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IncompleteDownloadException(Exception):
|
|
27
|
+
""" "Raised when the file is not downloaded completely."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InvalidMapcheteEOCollectionError(Exception):
|
|
31
|
+
""" "Raised for unsupported collections of Mapchete EO package."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EmptyCatalogueResponse(Exception):
|
|
35
|
+
"""Raised when catalogue response is empty."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CorruptedGTiffError(Exception):
|
|
39
|
+
"""Raised when GTiff validation fails."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BRDFError(Exception):
|
|
43
|
+
"""Raised when BRDF grid cannot be calculated."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AssetError(Exception):
|
|
47
|
+
"""Generic Exception class for Assets."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AssetMissing(AssetError, FileNotFoundError):
|
|
51
|
+
"""Raised when a product asset should be there but isn't."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AssetEmpty(AssetError):
|
|
55
|
+
"""Raised when a product asset should contain data but is empty."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AssetKeyError(AssetError, KeyError):
|
|
59
|
+
"""Raised when an asset name cannot be found in item."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PreprocessingNotFinished(Exception):
|
|
63
|
+
"""Raised when preprocessing tasks have not been fully executed."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AllMasked(Exception):
|
|
67
|
+
"""Raised when an array is fully masked."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NoSourceProducts(MapcheteNodataTile, ValueError):
|
|
71
|
+
"""Raised when no products are available."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CorruptedProduct(Exception):
|
|
75
|
+
"""Raised when product is damaged and cannot be read."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CorruptedProductMetadata(CorruptedProduct):
|
|
79
|
+
"""Raised when EOProduct cannot be parsed due to a metadata issue."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CorruptedSlice(Exception):
|
|
83
|
+
"""Raised when all products in a slice are damaged and cannot be read."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ItemGeometryError(Exception):
|
|
87
|
+
"""Raised when STAC item geometry cannot be resolved."""
|
mapchete_eo/geometry.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
from functools import partial
|
|
4
|
+
from typing import Callable, Iterable, Tuple
|
|
5
|
+
|
|
6
|
+
from fiona.crs import CRS
|
|
7
|
+
from fiona.transform import transform as fiona_transform
|
|
8
|
+
from mapchete.geometry import reproject_geometry
|
|
9
|
+
from mapchete.types import Bounds, CRSLike
|
|
10
|
+
from shapely.geometry import (
|
|
11
|
+
GeometryCollection,
|
|
12
|
+
LinearRing,
|
|
13
|
+
LineString,
|
|
14
|
+
MultiLineString,
|
|
15
|
+
MultiPoint,
|
|
16
|
+
MultiPolygon,
|
|
17
|
+
Point,
|
|
18
|
+
Polygon,
|
|
19
|
+
box,
|
|
20
|
+
shape,
|
|
21
|
+
)
|
|
22
|
+
from shapely.geometry.base import BaseGeometry
|
|
23
|
+
from shapely.ops import unary_union
|
|
24
|
+
|
|
25
|
+
CoordArrays = Tuple[Iterable[float], Iterable[float]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def transform_to_latlon(
|
|
32
|
+
geometry: BaseGeometry, src_crs: CRSLike, width_threshold: float = 180.0
|
|
33
|
+
) -> BaseGeometry:
|
|
34
|
+
"""Transforms a geometry to lat/lon coordinates.
|
|
35
|
+
|
|
36
|
+
If resulting geometry crosses the Antimeridian it will be fixed by moving coordinates
|
|
37
|
+
from the Western Hemisphere to outside of the lat/lon bounds on the East, making sure
|
|
38
|
+
the correct geometry shape is preserved.
|
|
39
|
+
|
|
40
|
+
As a next step, repair_antimeridian_geometry() can be applied, which then splits up
|
|
41
|
+
this geometry into a multipart geometry where all of its subgeometries are within the
|
|
42
|
+
lat/lon bounds again.
|
|
43
|
+
"""
|
|
44
|
+
latlon_crs = CRS.from_epsg(4326)
|
|
45
|
+
|
|
46
|
+
def transform_shift_coords(coords: CoordArrays) -> CoordArrays:
|
|
47
|
+
out_x_coords, out_y_coords = fiona_transform(src_crs, latlon_crs, *coords)
|
|
48
|
+
if max(out_x_coords) - min(out_x_coords) > width_threshold:
|
|
49
|
+
# we probably have an antimeridian crossing here!
|
|
50
|
+
out_x_coords, out_y_coords = coords_longitudinal_shift(
|
|
51
|
+
coords_transform(coords, src_crs, latlon_crs), only_negative_coords=True
|
|
52
|
+
)
|
|
53
|
+
return (out_x_coords, out_y_coords)
|
|
54
|
+
|
|
55
|
+
return custom_transform(geometry, transform_shift_coords)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def repair_antimeridian_geometry(
|
|
59
|
+
geometry: BaseGeometry, width_threshold: float = 180.0
|
|
60
|
+
) -> BaseGeometry:
|
|
61
|
+
"""
|
|
62
|
+
Repair geometry and apply fix if it crosses the Antimeridian.
|
|
63
|
+
|
|
64
|
+
A geometry crosses the Antimeridian if it is at least partly outside of the
|
|
65
|
+
lat/lon bounding box or if its width exceeds a certain threshold. This can happen
|
|
66
|
+
after reprojection if the geometry coordinates are transformed separately and land
|
|
67
|
+
left and right of the Antimeridian, thus resulting in a polygon spanning almost the
|
|
68
|
+
whole lat/lon bounding box width.
|
|
69
|
+
"""
|
|
70
|
+
# repair geometry if it is broken
|
|
71
|
+
geometry = geometry.buffer(0)
|
|
72
|
+
latlon_bbox = box(-180, -90, 180, 90)
|
|
73
|
+
|
|
74
|
+
# only attempt to fix if geometry is too wide or reaches over the lat/lon bounds
|
|
75
|
+
if (
|
|
76
|
+
Bounds.from_inp(geometry).width >= width_threshold
|
|
77
|
+
or not geometry.difference(latlon_bbox).is_empty
|
|
78
|
+
):
|
|
79
|
+
# (1) shift only coordinates on the western hemisphere by 360°, thus "fixing"
|
|
80
|
+
# the footprint, but letting it cross the antimeridian
|
|
81
|
+
shifted_geometry = longitudinal_shift(geometry, only_negative_coords=True)
|
|
82
|
+
|
|
83
|
+
# (2) split up geometry in one outside of latlon bounds and one inside
|
|
84
|
+
inside = shifted_geometry.intersection(latlon_bbox)
|
|
85
|
+
outside = shifted_geometry.difference(latlon_bbox)
|
|
86
|
+
|
|
87
|
+
# (3) shift back only the polygon outside of latlon bounds by -360, thus moving
|
|
88
|
+
# it back to the western hemisphere
|
|
89
|
+
outside_shifted = longitudinal_shift(
|
|
90
|
+
outside, offset=-360, only_negative_coords=False
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# (4) create a MultiPolygon out from these two polygons
|
|
94
|
+
geometry = unary_union([inside, outside_shifted])
|
|
95
|
+
|
|
96
|
+
return geometry
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def buffer_antimeridian_safe(
|
|
100
|
+
footprint: BaseGeometry, buffer_m: float = 0
|
|
101
|
+
) -> BaseGeometry:
|
|
102
|
+
"""Buffer geometry by meters and make it Antimeridian-safe.
|
|
103
|
+
|
|
104
|
+
Safe means that if it crosses the Antimeridian and is a MultiPolygon,
|
|
105
|
+
the buffer will only be applied to the edges facing away from the Antimeridian
|
|
106
|
+
thus leaving the polygon intact if shifted back.
|
|
107
|
+
"""
|
|
108
|
+
if footprint.is_empty:
|
|
109
|
+
return footprint
|
|
110
|
+
|
|
111
|
+
# repair geometry if it is broken
|
|
112
|
+
footprint = footprint.buffer(0)
|
|
113
|
+
|
|
114
|
+
if not buffer_m:
|
|
115
|
+
return footprint
|
|
116
|
+
|
|
117
|
+
if isinstance(footprint, MultiPolygon):
|
|
118
|
+
# we have a shifted footprint here!
|
|
119
|
+
# (1) unshift one part
|
|
120
|
+
subpolygons = []
|
|
121
|
+
for polygon in footprint.geoms:
|
|
122
|
+
lon = polygon.centroid.x
|
|
123
|
+
if lon < 0:
|
|
124
|
+
polygon = longitudinal_shift(polygon)
|
|
125
|
+
subpolygons.append(polygon)
|
|
126
|
+
# (2) merge to single polygon
|
|
127
|
+
merged = unary_union(subpolygons)
|
|
128
|
+
|
|
129
|
+
# (3) apply buffer
|
|
130
|
+
if isinstance(merged, MultiPolygon):
|
|
131
|
+
buffered = unary_union(
|
|
132
|
+
[
|
|
133
|
+
buffer_antimeridian_safe(polygon, buffer_m=buffer_m)
|
|
134
|
+
for polygon in merged.geoms
|
|
135
|
+
]
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
buffered = buffer_antimeridian_safe(merged, buffer_m=buffer_m)
|
|
139
|
+
|
|
140
|
+
# (4) fix again
|
|
141
|
+
return repair_antimeridian_geometry(buffered)
|
|
142
|
+
|
|
143
|
+
# UTM zone CRS
|
|
144
|
+
utm_crs = latlon_to_utm_crs(footprint.centroid.y, footprint.centroid.x)
|
|
145
|
+
latlon_crs = CRS.from_string("EPSG:4326")
|
|
146
|
+
|
|
147
|
+
return transform_to_latlon(
|
|
148
|
+
reproject_geometry(
|
|
149
|
+
footprint, src_crs=latlon_crs, dst_crs=utm_crs, clip_to_crs_bounds=False
|
|
150
|
+
).buffer(buffer_m),
|
|
151
|
+
src_crs=utm_crs,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def longitudinal_shift(
|
|
156
|
+
geometry: BaseGeometry, offset: float = 360.0, only_negative_coords: bool = False
|
|
157
|
+
) -> BaseGeometry:
|
|
158
|
+
"""Return geometry with either all or Western hemisphere coordinates shifted by some offset."""
|
|
159
|
+
return custom_transform(
|
|
160
|
+
geometry,
|
|
161
|
+
partial(
|
|
162
|
+
coords_longitudinal_shift,
|
|
163
|
+
by=offset,
|
|
164
|
+
only_negative_coords=only_negative_coords,
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def latlon_to_utm_crs(lat: float, lon: float) -> CRS:
|
|
170
|
+
min_zone = 1
|
|
171
|
+
max_zone = 60
|
|
172
|
+
utm_zone = (
|
|
173
|
+
f"{max([min([(math.floor((lon + 180) / 6) + 1), max_zone]), min_zone]):02}"
|
|
174
|
+
)
|
|
175
|
+
hemisphere_code = "7" if lat <= 0 else "6"
|
|
176
|
+
return CRS.from_string(f"EPSG:32{hemisphere_code}{utm_zone}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def bounds_to_geom(bounds: Bounds) -> BaseGeometry:
|
|
180
|
+
# TODO: move into core package
|
|
181
|
+
if bounds.left < -180:
|
|
182
|
+
part1 = Bounds(-180, bounds.bottom, bounds.right, bounds.top)
|
|
183
|
+
part2 = Bounds(bounds.left + 360, bounds.bottom, 180, bounds.top)
|
|
184
|
+
return unary_union([shape(part1), shape(part2)])
|
|
185
|
+
elif bounds.right > 180:
|
|
186
|
+
part1 = Bounds(-180, bounds.bottom, bounds.right - 360, bounds.top)
|
|
187
|
+
part2 = Bounds(bounds.left, bounds.bottom, 180, bounds.top)
|
|
188
|
+
return unary_union([shape(part1), shape(part2)])
|
|
189
|
+
else:
|
|
190
|
+
return shape(bounds)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def custom_transform(geometry: BaseGeometry, func: Callable) -> BaseGeometry:
|
|
194
|
+
# todo: shapely.transform.transform maybe can make this code more simple
|
|
195
|
+
# https://shapely.readthedocs.io/en/stable/reference/shapely.transform.html#shapely.transform
|
|
196
|
+
def _point(point: Point) -> Point:
|
|
197
|
+
return Point(zip(*func(point.xy)))
|
|
198
|
+
|
|
199
|
+
def _multipoint(multipoint: MultiPoint) -> MultiPoint:
|
|
200
|
+
return MultiPoint([_point(point) for point in multipoint])
|
|
201
|
+
|
|
202
|
+
def _linestring(linestring: LineString) -> LineString:
|
|
203
|
+
return LineString(zip(*func(linestring.xy)))
|
|
204
|
+
|
|
205
|
+
def _multilinestring(multilinestring: MultiLineString) -> MultiLineString:
|
|
206
|
+
return MultiLineString(
|
|
207
|
+
[_linestring(linestring) for linestring in multilinestring.geoms]
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _linearring(linearring: LinearRing) -> LinearRing:
|
|
211
|
+
return LinearRing(((x, y) for x, y in zip(*func(linearring.xy))))
|
|
212
|
+
|
|
213
|
+
def _polygon(polygon: Polygon) -> Polygon:
|
|
214
|
+
return Polygon(
|
|
215
|
+
_linearring(polygon.exterior),
|
|
216
|
+
holes=list(map(_linearring, polygon.interiors)),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _multipolygon(multipolygon: MultiPolygon) -> MultiPolygon:
|
|
220
|
+
return MultiPolygon([_polygon(polygon) for polygon in multipolygon.geoms])
|
|
221
|
+
|
|
222
|
+
def _geometrycollection(
|
|
223
|
+
geometrycollection: GeometryCollection,
|
|
224
|
+
) -> GeometryCollection:
|
|
225
|
+
return GeometryCollection(
|
|
226
|
+
[_any_geometry(subgeometry) for subgeometry in geometrycollection.geoms]
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _any_geometry(geometry: BaseGeometry) -> BaseGeometry:
|
|
230
|
+
transform_funcs = {
|
|
231
|
+
Point: _point,
|
|
232
|
+
MultiPoint: _multipoint,
|
|
233
|
+
LineString: _linestring,
|
|
234
|
+
MultiLineString: _multilinestring,
|
|
235
|
+
Polygon: _polygon,
|
|
236
|
+
MultiPolygon: _multipolygon,
|
|
237
|
+
GeometryCollection: _geometrycollection,
|
|
238
|
+
}
|
|
239
|
+
try:
|
|
240
|
+
return transform_funcs[type(geometry)](geometry)
|
|
241
|
+
except KeyError:
|
|
242
|
+
raise TypeError(f"unknown geometry {geometry} of type {type(geometry)}")
|
|
243
|
+
|
|
244
|
+
if geometry.is_empty:
|
|
245
|
+
return geometry
|
|
246
|
+
|
|
247
|
+
# make valid by buffering
|
|
248
|
+
return _any_geometry(geometry).buffer(0)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def coords_transform(
|
|
252
|
+
coords: CoordArrays, src_crs: CRSLike, dst_crs: CRSLike
|
|
253
|
+
) -> CoordArrays:
|
|
254
|
+
return fiona_transform(src_crs, dst_crs, *coords)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def coords_longitudinal_shift(
|
|
258
|
+
coords: CoordArrays,
|
|
259
|
+
by: float = 360,
|
|
260
|
+
only_negative_coords: bool = False,
|
|
261
|
+
) -> CoordArrays:
|
|
262
|
+
x_coords, y_coords = coords
|
|
263
|
+
x_coords = (
|
|
264
|
+
(
|
|
265
|
+
x_coord + by
|
|
266
|
+
if (only_negative_coords and x_coord < 0) or not only_negative_coords
|
|
267
|
+
else x_coord
|
|
268
|
+
)
|
|
269
|
+
for x_coord in x_coords
|
|
270
|
+
)
|
|
271
|
+
return x_coords, y_coords
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from mapchete_eo.image_operations.color_correction import color_correct
|
|
2
|
+
from mapchete_eo.image_operations.dtype_scale import dtype_scale
|
|
3
|
+
from mapchete_eo.image_operations.fillnodata import FillSelectionMethod, fillnodata
|
|
4
|
+
from mapchete_eo.image_operations.linear_normalization import linear_normalization
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"color_correct",
|
|
8
|
+
"dtype_scale",
|
|
9
|
+
"fillnodata",
|
|
10
|
+
"FillSelectionMethod",
|
|
11
|
+
"linear_normalization",
|
|
12
|
+
]
|