sh-batch-grid-builder 0.2.1__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.
- sh_batch_grid_builder/__init__.py +11 -0
- sh_batch_grid_builder/cli.py +162 -0
- sh_batch_grid_builder/crs.py +62 -0
- sh_batch_grid_builder/geo.py +289 -0
- sh_batch_grid_builder-0.2.1.dist-info/METADATA +187 -0
- sh_batch_grid_builder-0.2.1.dist-info/RECORD +9 -0
- sh_batch_grid_builder-0.2.1.dist-info/WHEEL +5 -0
- sh_batch_grid_builder-0.2.1.dist-info/entry_points.txt +2 -0
- sh_batch_grid_builder-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SH Batch Grid Builder
|
|
3
|
+
|
|
4
|
+
A tool for generating aligned bounding boxes and pixelated geometries from AOI files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from sh_batch_grid_builder.geo import GeoData
|
|
8
|
+
from sh_batch_grid_builder.crs import get_crs_data, get_crs_units
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["GeoData", "get_crs_data", "get_crs_units"]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Command-line interface for SH Batch Grid Builder.
|
|
4
|
+
|
|
5
|
+
This tool generates bounding boxes or pixelated geometries from AOI files.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from sh_batch_grid_builder.geo import GeoData
|
|
11
|
+
from sh_batch_grid_builder.crs import get_crs_units
|
|
12
|
+
|
|
13
|
+
# Fixed maximum pixels setting - geometries will be automatically split if they exceed this limit
|
|
14
|
+
MAX_PIXELS = 3500
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_resolution(value: str) -> tuple[float, float]:
|
|
18
|
+
cleaned = value.strip()
|
|
19
|
+
if cleaned.startswith("(") and cleaned.endswith(")"):
|
|
20
|
+
cleaned = cleaned[1:-1].strip()
|
|
21
|
+
parts = [part.strip() for part in cleaned.split(",") if part.strip()]
|
|
22
|
+
if len(parts) != 2:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
"Resolution must be provided as '(x,y)' or 'x,y' with two values."
|
|
25
|
+
)
|
|
26
|
+
try:
|
|
27
|
+
resolution_x = float(parts[0])
|
|
28
|
+
resolution_y = float(parts[1])
|
|
29
|
+
except ValueError as exc:
|
|
30
|
+
raise ValueError("Resolution values must be numeric.") from exc
|
|
31
|
+
if resolution_x <= 0 or resolution_y <= 0:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"Resolution must be positive, got ({resolution_x}, {resolution_y})"
|
|
34
|
+
)
|
|
35
|
+
return resolution_x, resolution_y
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main():
|
|
39
|
+
"""Main entry point for the CLI."""
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
description="Generate bounding boxes or pixelated geometries from AOI files",
|
|
42
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
43
|
+
epilog="""
|
|
44
|
+
Examples:
|
|
45
|
+
# Generate bounding box
|
|
46
|
+
sh-grid-builder input.geojson --resolution "(10,10)" --epsg 3035 --output-type bounding-box -o output.gpkg
|
|
47
|
+
|
|
48
|
+
# Generate pixelated geometry
|
|
49
|
+
sh-grid-builder input.geojson --resolution "10,10" --epsg 3035 --output-type pixelated -o output.gpkg
|
|
50
|
+
""",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"input_aoi",
|
|
55
|
+
type=str,
|
|
56
|
+
help="Path to input AOI file (GeoJSON, GPKG, or other geospatial formats supported by GeoPandas)",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--resolution",
|
|
61
|
+
type=str,
|
|
62
|
+
required=True,
|
|
63
|
+
help=(
|
|
64
|
+
"Grid resolution as '(x,y)' or 'x,y' in CRS coordinate units "
|
|
65
|
+
"(e.g., '(10,10)' for 10 meters, or '0.001,0.001' for degrees)"
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--epsg",
|
|
71
|
+
type=int,
|
|
72
|
+
required=True,
|
|
73
|
+
help="EPSG code for the output CRS (e.g., 3035 for ETRS89 / LAEA Europe)",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--output-type",
|
|
78
|
+
type=str,
|
|
79
|
+
choices=["bounding-box", "pixelated"],
|
|
80
|
+
required=True,
|
|
81
|
+
help="Type of output to generate: 'bounding-box' for aligned bounding boxes, 'pixelated' for pixelated geometry",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"-o",
|
|
86
|
+
"--output",
|
|
87
|
+
type=str,
|
|
88
|
+
required=True,
|
|
89
|
+
help="Path to output file (GPKG format required)",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
args = parser.parse_args()
|
|
93
|
+
|
|
94
|
+
# Validate input file exists
|
|
95
|
+
input_path = Path(args.input_aoi)
|
|
96
|
+
if not input_path.exists():
|
|
97
|
+
print(f"Error: Input file '{args.input_aoi}' does not exist.", file=sys.stderr)
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
# Parse and validate resolution
|
|
101
|
+
try:
|
|
102
|
+
resolution_x, resolution_y = _parse_resolution(args.resolution)
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
# Check CRS units and warn user
|
|
108
|
+
try:
|
|
109
|
+
crs_units = get_crs_units(args.epsg)
|
|
110
|
+
print(f"CRS EPSG:{args.epsg} uses units: {crs_units}")
|
|
111
|
+
print(f"Resolution: ({resolution_x}, {resolution_y}) {crs_units}")
|
|
112
|
+
|
|
113
|
+
# Warn if using geographic CRS (degrees) with potentially inappropriate resolution
|
|
114
|
+
if crs_units == "degrees":
|
|
115
|
+
if resolution_x > 1.0 or resolution_y > 1.0:
|
|
116
|
+
print(
|
|
117
|
+
f"Warning: Resolution of ({resolution_x}, {resolution_y}) degrees is very large. "
|
|
118
|
+
f"For EPSG:{args.epsg} (geographic CRS), resolution should be in degrees. "
|
|
119
|
+
f"Typical values are small (e.g., 0.001 degrees ≈ 111 meters).",
|
|
120
|
+
file=sys.stderr,
|
|
121
|
+
)
|
|
122
|
+
elif resolution_x < 0.00001 or resolution_y < 0.00001:
|
|
123
|
+
print(
|
|
124
|
+
f"Warning: Resolution of ({resolution_x}, {resolution_y}) degrees is very small. "
|
|
125
|
+
f"This may result in extremely large pixel counts.",
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
print(f"Warning: Could not determine CRS units: {e}", file=sys.stderr)
|
|
130
|
+
print("Proceeding with resolution as provided...", file=sys.stderr)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
# Initialize GeoData
|
|
134
|
+
print(f"Loading AOI from: {args.input_aoi}")
|
|
135
|
+
geo_data = GeoData(input_path, args.epsg, resolution_x, resolution_y)
|
|
136
|
+
|
|
137
|
+
# Generate output based on type
|
|
138
|
+
if args.output_type == "bounding-box":
|
|
139
|
+
print(
|
|
140
|
+
f"Generating aligned bounding box(es) with resolution ({resolution_x}, {resolution_y})..."
|
|
141
|
+
)
|
|
142
|
+
result_gdf = geo_data.create_aligned_bounding_box(max_pixels=MAX_PIXELS)
|
|
143
|
+
print(f"Created {len(result_gdf)} aligned bounding box(es)")
|
|
144
|
+
else: # pixelated
|
|
145
|
+
print(
|
|
146
|
+
f"Generating pixelated geometry with resolution ({resolution_x}, {resolution_y})..."
|
|
147
|
+
)
|
|
148
|
+
result_gdf = geo_data.create_pixelated_geometry(max_pixels=MAX_PIXELS)
|
|
149
|
+
print(f"Created {len(result_gdf)} pixelated geometry/geometries")
|
|
150
|
+
|
|
151
|
+
# Save output
|
|
152
|
+
output_path = Path(args.output)
|
|
153
|
+
result_gdf.to_file(output_path, driver="GPKG")
|
|
154
|
+
print(f"Successfully saved {len(result_gdf)} feature(s) to {output_path}")
|
|
155
|
+
|
|
156
|
+
except Exception as e:
|
|
157
|
+
print(f"Error: {str(e)}", file=sys.stderr)
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
main()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from typing import Tuple
|
|
2
|
+
from pyproj import CRS
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_crs_data(target_epsg: int) -> Tuple[float, float]:
|
|
6
|
+
crs = CRS.from_epsg(target_epsg)
|
|
7
|
+
|
|
8
|
+
origin_x = 0.0
|
|
9
|
+
origin_y = 0.0
|
|
10
|
+
|
|
11
|
+
if crs.is_projected:
|
|
12
|
+
for param in crs.coordinate_operation.params:
|
|
13
|
+
if "easting" in param.name.lower() or "false easting" in param.name.lower():
|
|
14
|
+
origin_x = param.value
|
|
15
|
+
if (
|
|
16
|
+
"northing" in param.name.lower()
|
|
17
|
+
or "false northing" in param.name.lower()
|
|
18
|
+
):
|
|
19
|
+
origin_y = param.value
|
|
20
|
+
|
|
21
|
+
return origin_x, origin_y
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_crs_units(target_epsg: int) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Get the units of the CRS.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
target_epsg: EPSG code
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
String describing the units (e.g., "degrees", "metre", "meter", "foot", etc.)
|
|
33
|
+
"""
|
|
34
|
+
crs = CRS.from_epsg(target_epsg)
|
|
35
|
+
|
|
36
|
+
if crs.is_geographic:
|
|
37
|
+
return "degrees"
|
|
38
|
+
elif crs.is_projected:
|
|
39
|
+
# Get units from axis info
|
|
40
|
+
if crs.axis_info:
|
|
41
|
+
unit_name = crs.axis_info[0].unit_name.lower()
|
|
42
|
+
# Normalize common variations
|
|
43
|
+
if unit_name in ["metre", "meter", "m"]:
|
|
44
|
+
return "meters"
|
|
45
|
+
elif unit_name in ["degree", "degrees", "deg"]:
|
|
46
|
+
return "degrees"
|
|
47
|
+
elif unit_name in ["foot", "feet", "ft"]:
|
|
48
|
+
return "feet"
|
|
49
|
+
else:
|
|
50
|
+
return unit_name
|
|
51
|
+
else:
|
|
52
|
+
# Default for projected CRS is usually meters
|
|
53
|
+
return "meters"
|
|
54
|
+
else:
|
|
55
|
+
# Fallback - try to get from CRS string
|
|
56
|
+
crs_str = str(crs).lower()
|
|
57
|
+
if "degree" in crs_str:
|
|
58
|
+
return "degrees"
|
|
59
|
+
elif "metre" in crs_str or "meter" in crs_str:
|
|
60
|
+
return "meters"
|
|
61
|
+
else:
|
|
62
|
+
return "unknown"
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Union
|
|
3
|
+
import math
|
|
4
|
+
import geopandas as gpd
|
|
5
|
+
from shapely.geometry import box, shape
|
|
6
|
+
from shapely.ops import unary_union
|
|
7
|
+
from sh_batch_grid_builder.crs import get_crs_data
|
|
8
|
+
from pyproj import CRS
|
|
9
|
+
from rasterio import features
|
|
10
|
+
from rasterio.transform import from_origin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GeoData:
|
|
14
|
+
"""
|
|
15
|
+
A class for working with geodata and creating aligned bounding boxes to the projection grid.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
filepath: Path to the input geodata file
|
|
19
|
+
epsg_code: EPSG code of the input geodata
|
|
20
|
+
resolution_x: Resolution of the input geodata in x direction
|
|
21
|
+
resolution_y: Resolution of the input geodata in y direction
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
filepath: Union[str, Path],
|
|
27
|
+
epsg_code: int,
|
|
28
|
+
resolution_x: float,
|
|
29
|
+
resolution_y: float,
|
|
30
|
+
):
|
|
31
|
+
self.gdf = self.read_geodata(filepath)
|
|
32
|
+
self.crs = epsg_code
|
|
33
|
+
self.bounds = self.gdf.total_bounds
|
|
34
|
+
|
|
35
|
+
self.resolution_x = resolution_x
|
|
36
|
+
self.resolution_y = resolution_y
|
|
37
|
+
self._validate_resolutions()
|
|
38
|
+
|
|
39
|
+
self._validate_epsg(epsg_code)
|
|
40
|
+
self.epsg_code = epsg_code
|
|
41
|
+
|
|
42
|
+
def _validate_resolutions(self):
|
|
43
|
+
if self.resolution_x <= 0:
|
|
44
|
+
raise ValueError(f"Resolution X must be positive, got {self.resolution_x}")
|
|
45
|
+
if self.resolution_y <= 0:
|
|
46
|
+
raise ValueError(f"Resolution Y must be positive, got {self.resolution_y}")
|
|
47
|
+
|
|
48
|
+
def _validate_epsg(self, epsg_code: int):
|
|
49
|
+
if self.gdf.crs.to_epsg() is None:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Could not determine EPSG code from input file CRS. "
|
|
52
|
+
f"Expected EPSG:{epsg_code}. Please ensure the file has a valid EPSG CRS."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if self.gdf.crs.to_epsg() != epsg_code:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Input file CRS (EPSG:{self.gdf.crs.to_epsg()}) does not match target EPSG ({epsg_code}). "
|
|
58
|
+
f"Please reproject the input file to EPSG:{epsg_code} before processing, "
|
|
59
|
+
f"or use EPSG:{self.gdf.crs.to_epsg()} as the target EPSG."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _align_axis(
|
|
63
|
+
self, minv: float, maxv: float, origin: float, res: float
|
|
64
|
+
) -> tuple[float, float]:
|
|
65
|
+
# Snap to grid defined by origin + k*res, with epsilon to avoid off-by-one.
|
|
66
|
+
eps = res * 1e-9
|
|
67
|
+
min_idx = math.floor((minv - origin) / res + eps)
|
|
68
|
+
max_idx = math.ceil((maxv - origin) / res - eps)
|
|
69
|
+
|
|
70
|
+
aligned_min = origin + min_idx * res
|
|
71
|
+
aligned_max = origin + max_idx * res
|
|
72
|
+
|
|
73
|
+
# Normalize length to an integer number of steps (guards floating error).
|
|
74
|
+
steps = max(1, int(round((aligned_max - aligned_min) / res)))
|
|
75
|
+
aligned_max = aligned_min + steps * res
|
|
76
|
+
|
|
77
|
+
return aligned_min, aligned_max
|
|
78
|
+
|
|
79
|
+
def _split_pixel_counts(self, total: int, parts: int) -> list[int]:
|
|
80
|
+
base = total // parts
|
|
81
|
+
remainder = total % parts
|
|
82
|
+
return [base + 1 if i < remainder else base for i in range(parts)]
|
|
83
|
+
|
|
84
|
+
def _grid_origin(self) -> tuple[float, float]:
|
|
85
|
+
origin_x, origin_y = get_crs_data(self.crs)
|
|
86
|
+
if not CRS.from_epsg(self.crs).is_projected:
|
|
87
|
+
origin_x -= self.resolution_x / 2
|
|
88
|
+
origin_y -= self.resolution_y / 2
|
|
89
|
+
return origin_x, origin_y
|
|
90
|
+
|
|
91
|
+
def read_geodata(self, filepath: Union[str, Path]):
|
|
92
|
+
gdf = gpd.read_file(filepath)
|
|
93
|
+
return gdf
|
|
94
|
+
|
|
95
|
+
def create_aligned_bounding_box(self, max_pixels: int = 3500) -> gpd.GeoDataFrame:
|
|
96
|
+
"""
|
|
97
|
+
Create an aligned bounding box to the projection grid that covers the input geometry.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
max_pixels: Maximum allowed pixels in either dimension (default: 3500)
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
GeoDataFrame with one or more bounding boxes (split if exceeds max_pixels)
|
|
104
|
+
"""
|
|
105
|
+
# Get the grid origin from the CRS
|
|
106
|
+
origin_x, origin_y = self._grid_origin()
|
|
107
|
+
|
|
108
|
+
# Get the grid bounds of the input geometry
|
|
109
|
+
minx, miny, maxx, maxy = self.bounds
|
|
110
|
+
|
|
111
|
+
aligned_minx, aligned_maxx = self._align_axis(
|
|
112
|
+
minx, maxx, origin_x, self.resolution_x
|
|
113
|
+
)
|
|
114
|
+
aligned_miny, aligned_maxy = self._align_axis(
|
|
115
|
+
miny, maxy, origin_y, self.resolution_y
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Calculate width and height in pixels of the aligned bounding box
|
|
119
|
+
width_px = int(round((aligned_maxx - aligned_minx) / self.resolution_x))
|
|
120
|
+
height_px = int(round((aligned_maxy - aligned_miny) / self.resolution_y))
|
|
121
|
+
|
|
122
|
+
tiles_x = max(1, math.ceil(width_px / max_pixels))
|
|
123
|
+
tiles_y = max(1, math.ceil(height_px / max_pixels))
|
|
124
|
+
|
|
125
|
+
widths = self._split_pixel_counts(width_px, tiles_x)
|
|
126
|
+
heights = self._split_pixel_counts(height_px, tiles_y)
|
|
127
|
+
|
|
128
|
+
geometries = []
|
|
129
|
+
y_min = aligned_miny
|
|
130
|
+
for row_idx, tile_h in enumerate(heights, start=1):
|
|
131
|
+
y_max = y_min + tile_h * self.resolution_y
|
|
132
|
+
x_min = aligned_minx
|
|
133
|
+
for col_idx, tile_w in enumerate(widths, start=1):
|
|
134
|
+
x_max = x_min + tile_w * self.resolution_x
|
|
135
|
+
geometries.append(
|
|
136
|
+
{
|
|
137
|
+
"geometry": box(x_min, y_min, x_max, y_max),
|
|
138
|
+
"width": tile_w,
|
|
139
|
+
"height": tile_h,
|
|
140
|
+
"row_idx": row_idx,
|
|
141
|
+
"col_idx": col_idx,
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
x_min = x_max
|
|
145
|
+
y_min = y_max
|
|
146
|
+
|
|
147
|
+
if not geometries:
|
|
148
|
+
return gpd.GeoDataFrame(
|
|
149
|
+
[],
|
|
150
|
+
columns=["id", "identifier", "width", "height", "geometry"],
|
|
151
|
+
crs=CRS.from_epsg(self.crs),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
aoi_union = unary_union(self.gdf.geometry)
|
|
155
|
+
filtered_tiles = [
|
|
156
|
+
tile for tile in geometries if tile["geometry"].intersects(aoi_union)
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
if not filtered_tiles:
|
|
160
|
+
return gpd.GeoDataFrame(
|
|
161
|
+
[],
|
|
162
|
+
columns=["id", "identifier", "width", "height", "geometry"],
|
|
163
|
+
crs=CRS.from_epsg(self.crs),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Renumber tiles without gaps in row/col identifiers
|
|
167
|
+
filtered_tiles.sort(key=lambda tile: (tile["row_idx"], tile["col_idx"]))
|
|
168
|
+
renumbered_tiles = []
|
|
169
|
+
current_row = None
|
|
170
|
+
row_counter = 0
|
|
171
|
+
col_counter = 0
|
|
172
|
+
for tile in filtered_tiles:
|
|
173
|
+
if tile["row_idx"] != current_row:
|
|
174
|
+
row_counter += 1
|
|
175
|
+
current_row = tile["row_idx"]
|
|
176
|
+
col_counter = 0
|
|
177
|
+
col_counter += 1
|
|
178
|
+
renumbered_tiles.append(
|
|
179
|
+
{
|
|
180
|
+
"geometry": tile["geometry"],
|
|
181
|
+
"width": tile["width"],
|
|
182
|
+
"height": tile["height"],
|
|
183
|
+
"identifier": f"tile_{col_counter}_{row_counter}",
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Create GeoDataFrame and renumber sequentially
|
|
188
|
+
bbox_gdf = gpd.GeoDataFrame(renumbered_tiles, crs=CRS.from_epsg(self.crs))
|
|
189
|
+
bbox_gdf["id"] = range(1, len(bbox_gdf) + 1)
|
|
190
|
+
|
|
191
|
+
# Reorder columns to match expected format
|
|
192
|
+
bbox_gdf = bbox_gdf[["id", "identifier", "width", "height", "geometry"]]
|
|
193
|
+
|
|
194
|
+
return bbox_gdf
|
|
195
|
+
|
|
196
|
+
def create_pixelated_geometry(self, max_pixels: int = 3500) -> gpd.GeoDataFrame:
|
|
197
|
+
"""
|
|
198
|
+
Rasterize AOI to aligned grid, then dissolve connected pixels into polygons.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
max_pixels: Maximum allowed pixels in either dimension (default: 3500)
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
GeoDataFrame of dissolved pixel groups.
|
|
205
|
+
"""
|
|
206
|
+
# Get the grid origin from the CRS
|
|
207
|
+
origin_x, origin_y = self._grid_origin()
|
|
208
|
+
|
|
209
|
+
# Get the grid bounds of the input geometry
|
|
210
|
+
minx, miny, maxx, maxy = self.bounds
|
|
211
|
+
|
|
212
|
+
aligned_minx, aligned_maxx = self._align_axis(
|
|
213
|
+
minx, maxx, origin_x, self.resolution_x
|
|
214
|
+
)
|
|
215
|
+
aligned_miny, aligned_maxy = self._align_axis(
|
|
216
|
+
miny, maxy, origin_y, self.resolution_y
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Calculate width and height in pixels of the aligned bounding box
|
|
220
|
+
width_px = int(round((aligned_maxx - aligned_minx) / self.resolution_x))
|
|
221
|
+
height_px = int(round((aligned_maxy - aligned_miny) / self.resolution_y))
|
|
222
|
+
|
|
223
|
+
if width_px <= 0 or height_px <= 0:
|
|
224
|
+
return gpd.GeoDataFrame([], crs=self.gdf.crs)
|
|
225
|
+
|
|
226
|
+
# Snap max bounds to the pixel grid derived from the pixel counts.
|
|
227
|
+
aligned_maxx = aligned_minx + width_px * self.resolution_x
|
|
228
|
+
aligned_maxy = aligned_miny + height_px * self.resolution_y
|
|
229
|
+
|
|
230
|
+
transform = from_origin(
|
|
231
|
+
aligned_minx, aligned_maxy, self.resolution_x, self.resolution_y
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
shapes = ((geom, 1) for geom in self.gdf.geometry)
|
|
235
|
+
mask = features.rasterize(
|
|
236
|
+
shapes=shapes,
|
|
237
|
+
out_shape=(height_px, width_px),
|
|
238
|
+
transform=transform,
|
|
239
|
+
fill=0,
|
|
240
|
+
all_touched=True,
|
|
241
|
+
dtype="uint8",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
tiles_x = max(1, math.ceil(width_px / max_pixels))
|
|
245
|
+
tiles_y = max(1, math.ceil(height_px / max_pixels))
|
|
246
|
+
|
|
247
|
+
widths = self._split_pixel_counts(width_px, tiles_x)
|
|
248
|
+
heights = self._split_pixel_counts(height_px, tiles_y)
|
|
249
|
+
|
|
250
|
+
split_polygons = []
|
|
251
|
+
y_offset = 0
|
|
252
|
+
for row_idx, tile_h in enumerate(heights, start=1):
|
|
253
|
+
x_offset = 0
|
|
254
|
+
for col_idx, tile_w in enumerate(widths, start=1):
|
|
255
|
+
window = mask[
|
|
256
|
+
y_offset : y_offset + tile_h,
|
|
257
|
+
x_offset : x_offset + tile_w,
|
|
258
|
+
]
|
|
259
|
+
if window.any():
|
|
260
|
+
tile_identifier = f"tile_{col_idx}_{row_idx}"
|
|
261
|
+
window_transform = from_origin(
|
|
262
|
+
aligned_minx + x_offset * self.resolution_x,
|
|
263
|
+
aligned_maxy - y_offset * self.resolution_y,
|
|
264
|
+
self.resolution_x,
|
|
265
|
+
self.resolution_y,
|
|
266
|
+
)
|
|
267
|
+
feature_idx = 0
|
|
268
|
+
for geom, value in features.shapes(
|
|
269
|
+
window, mask=window > 0, transform=window_transform
|
|
270
|
+
):
|
|
271
|
+
feature_idx += 1
|
|
272
|
+
split_polygons.append(
|
|
273
|
+
{
|
|
274
|
+
"geometry": shape(geom),
|
|
275
|
+
"width": tile_w,
|
|
276
|
+
"height": tile_h,
|
|
277
|
+
"identifier": f"{tile_identifier}_{feature_idx}",
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
x_offset += tile_w
|
|
281
|
+
y_offset += tile_h
|
|
282
|
+
|
|
283
|
+
if not split_polygons:
|
|
284
|
+
return gpd.GeoDataFrame([], crs=self.gdf.crs)
|
|
285
|
+
|
|
286
|
+
pixel_gdf = gpd.GeoDataFrame(split_polygons, crs=self.gdf.crs)
|
|
287
|
+
pixel_gdf["id"] = range(1, len(pixel_gdf) + 1)
|
|
288
|
+
pixel_gdf = pixel_gdf[["id", "identifier", "width", "height", "geometry"]]
|
|
289
|
+
return pixel_gdf
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sh-batch-grid-builder
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: A tool for generating projection grid aligned bounding boxes and pixelated geometries from AOI (Area of Interest) files for Sentinel Hub Batch V2 API on CDSE
|
|
5
|
+
Author-email: Maxim Lamare <maxim.lamare@sinergise.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: gis,geospatial,grid,pixelation,bounding-box
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: geopandas>=0.12.0
|
|
21
|
+
Requires-Dist: pyproj>=3.4.0
|
|
22
|
+
Requires-Dist: shapely>=2.0.0
|
|
23
|
+
Requires-Dist: rasterio>=1.3.0
|
|
24
|
+
Requires-Dist: numpy>=1.21.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# SH Batch Grid Builder
|
|
30
|
+
|
|
31
|
+
This tool is designed to build custom tiling grids for the [Sentinel Hub Batch V2 API](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/BatchV2.html) on [CDSE](https://dataspace.copernicus.eu/). The custom grid is built around an input AOI for a given projection and ensures the Batch request produces outputs matching the pixel grid of the given projection.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Aligned Bounding Boxes**: Generate projection grid-aligned bounding boxes that snap to a specified grid resolution
|
|
36
|
+
- **Pixelated Geometries**: Convert geometries to pixelated representations in order to only query data for the given AOI
|
|
37
|
+
- **Automatic Splitting**: Automatically splits large geometries that exceed pixel limits
|
|
38
|
+
- **Multiple CRS Support**: Works with any EPSG code, automatically handling CRS-specific grid origins
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
### From PyPI
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install sh-batch-grid-builder
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### From Source
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/maximlamare/SH-Batch-Grid-Builder.git
|
|
52
|
+
cd SH-Batch-Grid-Builder
|
|
53
|
+
pip install .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Development Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/maximlamare/SH-Batch-Grid-Builder.git
|
|
60
|
+
cd SH-Batch-Grid-Builder
|
|
61
|
+
pip install -e ".[dev]"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### Command Line Interface
|
|
67
|
+
|
|
68
|
+
The tool provides a command-line interface via the `sh-grid-builder` command:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
sh-grid-builder <input_aoi> --resolution "(x,y)" --epsg <epsg_code> --output-type <type> -o <output_file>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
#### Arguments
|
|
75
|
+
|
|
76
|
+
- `input_aoi`: Path to input AOI file (GeoJSON, GPKG, or other formats supported by GeoPandas)
|
|
77
|
+
- `--resolution`: Grid resolution as `(x,y)` tuple in CRS coordinate units:
|
|
78
|
+
- Format: `"(x,y)"` or `"x,y"` (brackets optional, e.g., `"(300,359)"` or `"300,359"`)
|
|
79
|
+
- **Important**: Always quote the resolution value to prevent shell interpretation (e.g., `--resolution "(300,359)"` or `--resolution "300,359"`)
|
|
80
|
+
- Note the resolution **must** be in the units of the selected projection (e.g. EPSG:3035: resolution in meters; EPSG:4326: resolution in degrees).
|
|
81
|
+
- X and Y resolutions can be different to support non-square pixels
|
|
82
|
+
- The tool automatically detects and displays the CRS units when running
|
|
83
|
+
- `--epsg`: EPSG code for the output CRS (e.g., `3035` for ETRS89 / LAEA Europe, `4326` for WGS84)
|
|
84
|
+
- `--output-type`: Type of output to generate:
|
|
85
|
+
- `bounding-box`: Generate an aligned bounding box that covers the AOI
|
|
86
|
+
- `pixelated`: Generate pixelated geometry of the AOI
|
|
87
|
+
- `-o, --output`: Path to output file (GPKG format required)
|
|
88
|
+
|
|
89
|
+
#### Examples
|
|
90
|
+
|
|
91
|
+
Generate aligned bounding boxes with same resolution for x and y:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
sh-grid-builder data/aoi.geojson --resolution "(10,10)" --epsg 3035 --output-type bounding-box -o output_bbox.gpkg
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Generate aligned bounding boxes with different x and y resolutions:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
sh-grid-builder data/aoi.geojson --resolution "(300,359)" --epsg 32632 --output-type bounding-box -o output_bbox.gpkg
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Generate pixelated geometry:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
sh-grid-builder data/aoi.geojson --resolution "10,10" --epsg 3035 --output-type pixelated -o output_pixelated.gpkg
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Example with geographic CRS (degrees):
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
sh-grid-builder data/aoi.geojson --resolution "(0.001,0.001)" --epsg 4326 --output-type bounding-box -o output_bbox.gpkg
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Python API
|
|
116
|
+
|
|
117
|
+
You can also use the package programmatically:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from sh_batch_grid_builder import GeoData
|
|
121
|
+
|
|
122
|
+
# Initialize with AOI file, EPSG code, and resolutions (x, y)
|
|
123
|
+
geo_data = GeoData("path/to/aoi.geojson", epsg_code=3035, resolution_x=10.0, resolution_y=10.0)
|
|
124
|
+
|
|
125
|
+
# Or with different x and y resolutions
|
|
126
|
+
geo_data = GeoData("path/to/aoi.geojson", epsg_code=4326, resolution_x=0.002976190476204, resolution_y=0.002976190476204)
|
|
127
|
+
|
|
128
|
+
# Generate aligned bounding boxes
|
|
129
|
+
aligned_bboxes = geo_data.create_aligned_bounding_box(max_pixels=3500)
|
|
130
|
+
|
|
131
|
+
# Generate pixelated geometry (includes all pixels that touch/intersect the AOI)
|
|
132
|
+
pixelated_geom = geo_data.create_pixelated_geometry(max_pixels=3500)
|
|
133
|
+
|
|
134
|
+
# Save results
|
|
135
|
+
aligned_bboxes.to_file("output_bbox.gpkg", driver="GPKG")
|
|
136
|
+
pixelated_geom.to_file("output_pixelated.gpkg", driver="GPKG")
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## How It Works
|
|
140
|
+
|
|
141
|
+
### Aligned Bounding Boxes
|
|
142
|
+
|
|
143
|
+
The tool creates bounding boxes that are aligned to a grid based on:
|
|
144
|
+
1. The specified X and Y resolutions (can be different for non-square pixels)
|
|
145
|
+
2. The CRS origin (false easting/northing) for projected coordinate systems
|
|
146
|
+
3. Automatic splitting when dimensions exceed 3500 pixels (fixed limit)
|
|
147
|
+
|
|
148
|
+
### Pixelated Geometries
|
|
149
|
+
|
|
150
|
+
The pixelated geometry generation uses a raster-based approach:
|
|
151
|
+
1. Converts the input geometry to a raster mask
|
|
152
|
+
2. Polygonizes the raster back to vector format
|
|
153
|
+
3. Automatically splits large geometries to avoid memory issues
|
|
154
|
+
|
|
155
|
+
This approach is much faster than vector-based methods for large grids. Pixelated
|
|
156
|
+
output includes any pixel that touches/intersects the AOI.
|
|
157
|
+
|
|
158
|
+
## Requirements
|
|
159
|
+
|
|
160
|
+
- Python >= 3.8
|
|
161
|
+
- geopandas >= 0.12.0
|
|
162
|
+
- pyproj >= 3.4.0
|
|
163
|
+
- shapely >= 2.0.0
|
|
164
|
+
- rasterio >= 1.3.0
|
|
165
|
+
- numpy >= 1.21.0
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
### Running Tests
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
pytest
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Building the Package
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
python -m build
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT License
|
|
184
|
+
|
|
185
|
+
## Contributing
|
|
186
|
+
|
|
187
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
sh_batch_grid_builder/__init__.py,sha256=IwNxfpzkpN82cOJM3fSPCjFIfmMzu2VS1Yyhi72e9a4,307
|
|
2
|
+
sh_batch_grid_builder/cli.py,sha256=EbGqylr7nUnVy1r_hFCTYRB7_C1IbOIh58I9DYf3oIU,5791
|
|
3
|
+
sh_batch_grid_builder/crs.py,sha256=lkti354YjrOZJnyXJukHed3nUarxpnEuyALOohkr_hM,1823
|
|
4
|
+
sh_batch_grid_builder/geo.py,sha256=OXcNw4hYL0iw0aaW10PZu7mYyFxq4oVsJg5rmv1LFuw,10897
|
|
5
|
+
sh_batch_grid_builder-0.2.1.dist-info/METADATA,sha256=NoHqUV9g-TdvMhYaVtV0f1zcrNndto1lOUBx6PVeFNw,6351
|
|
6
|
+
sh_batch_grid_builder-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
+
sh_batch_grid_builder-0.2.1.dist-info/entry_points.txt,sha256=uRoZn0NdCainNiiiM7HrjpkC4RR2nRX2e5sdNtnlfgA,67
|
|
8
|
+
sh_batch_grid_builder-0.2.1.dist-info/top_level.txt,sha256=0Q3tnLNP4xBrh-UkrcXULMGZ7Te0GTvDBP3-FZ1qUYY,22
|
|
9
|
+
sh_batch_grid_builder-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sh_batch_grid_builder
|