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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sh-grid-builder = sh_batch_grid_builder.cli:main
@@ -0,0 +1 @@
1
+ sh_batch_grid_builder