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.
Files changed (87) hide show
  1. mapchete_eo/__init__.py +1 -0
  2. mapchete_eo/archives/__init__.py +0 -0
  3. mapchete_eo/archives/base.py +65 -0
  4. mapchete_eo/array/__init__.py +0 -0
  5. mapchete_eo/array/buffer.py +16 -0
  6. mapchete_eo/array/color.py +29 -0
  7. mapchete_eo/array/convert.py +157 -0
  8. mapchete_eo/base.py +528 -0
  9. mapchete_eo/blacklist.txt +175 -0
  10. mapchete_eo/cli/__init__.py +30 -0
  11. mapchete_eo/cli/bounds.py +22 -0
  12. mapchete_eo/cli/options_arguments.py +243 -0
  13. mapchete_eo/cli/s2_brdf.py +77 -0
  14. mapchete_eo/cli/s2_cat_results.py +146 -0
  15. mapchete_eo/cli/s2_find_broken_products.py +93 -0
  16. mapchete_eo/cli/s2_jp2_static_catalog.py +166 -0
  17. mapchete_eo/cli/s2_mask.py +71 -0
  18. mapchete_eo/cli/s2_mgrs.py +45 -0
  19. mapchete_eo/cli/s2_rgb.py +114 -0
  20. mapchete_eo/cli/s2_verify.py +129 -0
  21. mapchete_eo/cli/static_catalog.py +123 -0
  22. mapchete_eo/eostac.py +30 -0
  23. mapchete_eo/exceptions.py +87 -0
  24. mapchete_eo/geometry.py +271 -0
  25. mapchete_eo/image_operations/__init__.py +12 -0
  26. mapchete_eo/image_operations/color_correction.py +136 -0
  27. mapchete_eo/image_operations/compositing.py +247 -0
  28. mapchete_eo/image_operations/dtype_scale.py +43 -0
  29. mapchete_eo/image_operations/fillnodata.py +130 -0
  30. mapchete_eo/image_operations/filters.py +319 -0
  31. mapchete_eo/image_operations/linear_normalization.py +81 -0
  32. mapchete_eo/image_operations/sigmoidal.py +114 -0
  33. mapchete_eo/io/__init__.py +37 -0
  34. mapchete_eo/io/assets.py +492 -0
  35. mapchete_eo/io/items.py +147 -0
  36. mapchete_eo/io/levelled_cubes.py +228 -0
  37. mapchete_eo/io/path.py +144 -0
  38. mapchete_eo/io/products.py +413 -0
  39. mapchete_eo/io/profiles.py +45 -0
  40. mapchete_eo/known_catalogs.py +42 -0
  41. mapchete_eo/platforms/sentinel2/__init__.py +17 -0
  42. mapchete_eo/platforms/sentinel2/archives.py +190 -0
  43. mapchete_eo/platforms/sentinel2/bandpass_adjustment.py +104 -0
  44. mapchete_eo/platforms/sentinel2/brdf/__init__.py +8 -0
  45. mapchete_eo/platforms/sentinel2/brdf/config.py +32 -0
  46. mapchete_eo/platforms/sentinel2/brdf/correction.py +260 -0
  47. mapchete_eo/platforms/sentinel2/brdf/hls.py +251 -0
  48. mapchete_eo/platforms/sentinel2/brdf/models.py +44 -0
  49. mapchete_eo/platforms/sentinel2/brdf/protocols.py +27 -0
  50. mapchete_eo/platforms/sentinel2/brdf/ross_thick.py +136 -0
  51. mapchete_eo/platforms/sentinel2/brdf/sun_angle_arrays.py +76 -0
  52. mapchete_eo/platforms/sentinel2/config.py +181 -0
  53. mapchete_eo/platforms/sentinel2/driver.py +78 -0
  54. mapchete_eo/platforms/sentinel2/masks.py +325 -0
  55. mapchete_eo/platforms/sentinel2/metadata_parser.py +734 -0
  56. mapchete_eo/platforms/sentinel2/path_mappers/__init__.py +29 -0
  57. mapchete_eo/platforms/sentinel2/path_mappers/base.py +56 -0
  58. mapchete_eo/platforms/sentinel2/path_mappers/earthsearch.py +34 -0
  59. mapchete_eo/platforms/sentinel2/path_mappers/metadata_xml.py +135 -0
  60. mapchete_eo/platforms/sentinel2/path_mappers/sinergise.py +105 -0
  61. mapchete_eo/platforms/sentinel2/preprocessing_tasks.py +26 -0
  62. mapchete_eo/platforms/sentinel2/processing_baseline.py +160 -0
  63. mapchete_eo/platforms/sentinel2/product.py +669 -0
  64. mapchete_eo/platforms/sentinel2/types.py +109 -0
  65. mapchete_eo/processes/__init__.py +0 -0
  66. mapchete_eo/processes/config.py +51 -0
  67. mapchete_eo/processes/dtype_scale.py +112 -0
  68. mapchete_eo/processes/eo_to_xarray.py +19 -0
  69. mapchete_eo/processes/merge_rasters.py +235 -0
  70. mapchete_eo/product.py +278 -0
  71. mapchete_eo/protocols.py +56 -0
  72. mapchete_eo/search/__init__.py +14 -0
  73. mapchete_eo/search/base.py +222 -0
  74. mapchete_eo/search/config.py +42 -0
  75. mapchete_eo/search/s2_mgrs.py +314 -0
  76. mapchete_eo/search/stac_search.py +251 -0
  77. mapchete_eo/search/stac_static.py +236 -0
  78. mapchete_eo/search/utm_search.py +251 -0
  79. mapchete_eo/settings.py +24 -0
  80. mapchete_eo/sort.py +48 -0
  81. mapchete_eo/time.py +53 -0
  82. mapchete_eo/types.py +73 -0
  83. mapchete_eo-2025.7.0.dist-info/METADATA +38 -0
  84. mapchete_eo-2025.7.0.dist-info/RECORD +87 -0
  85. mapchete_eo-2025.7.0.dist-info/WHEEL +5 -0
  86. mapchete_eo-2025.7.0.dist-info/entry_points.txt +11 -0
  87. 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."""
@@ -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
+ ]