sarpyx 0.1.5__py3-none-any.whl → 0.1.6__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.
- docs/examples/advanced/batch_processing.py +1 -1
- docs/examples/advanced/custom_processing_chains.py +1 -1
- docs/examples/advanced/performance_optimization.py +1 -1
- docs/examples/basic/snap_integration.py +1 -1
- docs/examples/intermediate/quality_assessment.py +1 -1
- outputs/baseline/20260205-234828/__init__.py +33 -0
- outputs/baseline/20260205-234828/main.py +493 -0
- outputs/final/20260205-234851/__init__.py +33 -0
- outputs/final/20260205-234851/main.py +493 -0
- sarpyx/__init__.py +2 -2
- sarpyx/algorithms/__init__.py +2 -2
- sarpyx/cli/__init__.py +1 -1
- sarpyx/cli/focus.py +3 -5
- sarpyx/cli/main.py +106 -7
- sarpyx/cli/shipdet.py +1 -1
- sarpyx/cli/worldsar.py +549 -0
- sarpyx/processor/__init__.py +1 -1
- sarpyx/processor/core/decode.py +43 -8
- sarpyx/processor/core/focus.py +104 -57
- sarpyx/science/__init__.py +1 -1
- sarpyx/sla/__init__.py +8 -0
- sarpyx/sla/metrics.py +101 -0
- sarpyx/{snap → snapflow}/__init__.py +1 -1
- sarpyx/snapflow/engine.py +6165 -0
- sarpyx/{snap → snapflow}/op.py +0 -1
- sarpyx/utils/__init__.py +1 -1
- sarpyx/utils/geos.py +652 -0
- sarpyx/utils/grid.py +285 -0
- sarpyx/utils/io.py +77 -9
- sarpyx/utils/meta.py +55 -0
- sarpyx/utils/nisar_utils.py +652 -0
- sarpyx/utils/rfigen.py +108 -0
- sarpyx/utils/wkt_utils.py +109 -0
- sarpyx/utils/zarr_utils.py +55 -37
- {sarpyx-0.1.5.dist-info → sarpyx-0.1.6.dist-info}/METADATA +9 -5
- {sarpyx-0.1.5.dist-info → sarpyx-0.1.6.dist-info}/RECORD +41 -32
- {sarpyx-0.1.5.dist-info → sarpyx-0.1.6.dist-info}/WHEEL +1 -1
- sarpyx-0.1.6.dist-info/licenses/LICENSE +201 -0
- sarpyx-0.1.6.dist-info/top_level.txt +4 -0
- tests/test_zarr_compat.py +35 -0
- sarpyx/processor/core/decode_v0.py +0 -0
- sarpyx/processor/core/decode_v1.py +0 -849
- sarpyx/processor/core/focus_old.py +0 -1550
- sarpyx/processor/core/focus_v1.py +0 -1566
- sarpyx/processor/core/focus_v2.py +0 -1625
- sarpyx/snap/engine.py +0 -633
- sarpyx-0.1.5.dist-info/top_level.txt +0 -2
- {sarpyx-0.1.5.dist-info → sarpyx-0.1.6.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"""NISAR Product Reader and Cutter Utilities.
|
|
2
|
+
|
|
3
|
+
This module provides tools for reading NISAR GSLC (Geocoded Single Look Complex)
|
|
4
|
+
products and subsetting them based on WKT polygon geometries.
|
|
5
|
+
|
|
6
|
+
Classes:
|
|
7
|
+
NISARMetadata: Metadata container for NISAR products
|
|
8
|
+
NISARReader: Reader for NISAR HDF5 products
|
|
9
|
+
NISARCutter: Polygon-based product subsetting tool
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> from nisar_utils import NISARReader, NISARCutter
|
|
13
|
+
>>>
|
|
14
|
+
>>> # Read product
|
|
15
|
+
>>> reader = NISARReader('nisar_product.h5')
|
|
16
|
+
>>> info = reader.info()
|
|
17
|
+
>>>
|
|
18
|
+
>>> # Cut by WKT polygon
|
|
19
|
+
>>> cutter = NISARCutter(reader)
|
|
20
|
+
>>> wkt_polygon = 'POLYGON((x1 y1, x2 y2, x3 y3, x4 y4, x1 y1))'
|
|
21
|
+
>>> subset = cutter.cut_by_wkt(wkt_polygon, 'HH')
|
|
22
|
+
>>> cutter.save_subset(subset, 'output.tif')
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import h5py
|
|
26
|
+
import numpy as np
|
|
27
|
+
from typing import Dict, List, Tuple, Optional, Union
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
import rasterio
|
|
31
|
+
from rasterio.transform import Affine
|
|
32
|
+
from shapely import wkt
|
|
33
|
+
from shapely.geometry import Polygon
|
|
34
|
+
try:
|
|
35
|
+
from shapely.vectorized import contains
|
|
36
|
+
except ImportError:
|
|
37
|
+
from shapely import contains_xy as contains
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class NISARMetadata:
|
|
42
|
+
"""Metadata container for NISAR GSLC products.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
product_path: Path to the NISAR product file
|
|
46
|
+
frequency: Frequency band (e.g., 'frequencyA')
|
|
47
|
+
polarizations: List of available polarizations
|
|
48
|
+
grid_name: Grid name
|
|
49
|
+
shape: Data shape as (rows, cols)
|
|
50
|
+
epsg: EPSG code for coordinate system
|
|
51
|
+
x_spacing: Pixel spacing in X direction
|
|
52
|
+
y_spacing: Pixel spacing in Y direction
|
|
53
|
+
x_min: Minimum X coordinate
|
|
54
|
+
y_min: Minimum Y coordinate
|
|
55
|
+
x_max: Maximum X coordinate
|
|
56
|
+
y_max: Maximum Y coordinate
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
product_path: str
|
|
60
|
+
frequency: str
|
|
61
|
+
polarizations: List[str]
|
|
62
|
+
grid_name: str
|
|
63
|
+
shape: Tuple[int, int]
|
|
64
|
+
epsg: Optional[int]
|
|
65
|
+
x_spacing: float
|
|
66
|
+
y_spacing: float
|
|
67
|
+
x_min: float
|
|
68
|
+
y_min: float
|
|
69
|
+
x_max: float
|
|
70
|
+
y_max: float
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def bounds(self) -> Tuple[float, float, float, float]:
|
|
74
|
+
"""Return bounds as (xmin, ymin, xmax, ymax)."""
|
|
75
|
+
return (self.x_min, self.y_min, self.x_max, self.y_max)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def transform(self) -> Affine:
|
|
79
|
+
"""Return affine transform for georeferencing."""
|
|
80
|
+
return Affine.translation(self.x_min, self.y_max) * Affine.scale(
|
|
81
|
+
self.x_spacing, -abs(self.y_spacing)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class NISARReader:
|
|
86
|
+
"""Reader for NISAR GSLC (Geocoded Single Look Complex) products.
|
|
87
|
+
|
|
88
|
+
This class provides methods to read NISAR HDF5 products and extract
|
|
89
|
+
geocoded SAR data with proper geospatial metadata.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
product_path: Path to the NISAR HDF5 product file
|
|
93
|
+
|
|
94
|
+
Example:
|
|
95
|
+
>>> reader = NISARReader('nisar_product.h5')
|
|
96
|
+
>>> metadata = reader.get_metadata()
|
|
97
|
+
>>> data = reader.read_data('HH')
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, product_path: Union[str, Path]):
|
|
101
|
+
"""Initialize NISAR reader with product path.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
product_path: Path to NISAR HDF5 file
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
FileNotFoundError: If product file does not exist
|
|
108
|
+
"""
|
|
109
|
+
self.product_path = Path(product_path)
|
|
110
|
+
if not self.product_path.exists():
|
|
111
|
+
raise FileNotFoundError(f'Product file not found: {self.product_path}')
|
|
112
|
+
|
|
113
|
+
self._file = None
|
|
114
|
+
self._metadata_cache = {}
|
|
115
|
+
|
|
116
|
+
def __enter__(self):
|
|
117
|
+
"""Context manager entry."""
|
|
118
|
+
self._file = h5py.File(self.product_path, 'r')
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
122
|
+
"""Context manager exit."""
|
|
123
|
+
if self._file is not None:
|
|
124
|
+
self._file.close()
|
|
125
|
+
self._file = None
|
|
126
|
+
|
|
127
|
+
def _get_science_path(self, frequency: str = 'frequencyA') -> str:
|
|
128
|
+
"""Construct path to science data.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
frequency: Frequency band
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
HDF5 path string
|
|
135
|
+
"""
|
|
136
|
+
return f'science/LSAR/GSLC/grids/{frequency}'
|
|
137
|
+
|
|
138
|
+
def get_available_polarizations(self, frequency: str = 'frequencyA') -> List[str]:
|
|
139
|
+
"""Get list of available polarizations in the product.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
frequency: Frequency band (default: 'frequencyA')
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
List of polarization strings (e.g., ['HH', 'HV', 'VH', 'VV'])
|
|
146
|
+
"""
|
|
147
|
+
with h5py.File(self.product_path, 'r') as f:
|
|
148
|
+
science_path = self._get_science_path(frequency)
|
|
149
|
+
polarizations = []
|
|
150
|
+
|
|
151
|
+
if science_path in f:
|
|
152
|
+
for key in f[science_path].keys():
|
|
153
|
+
if key in ['HH', 'HV', 'VH', 'VV']:
|
|
154
|
+
polarizations.append(key)
|
|
155
|
+
|
|
156
|
+
return sorted(polarizations)
|
|
157
|
+
|
|
158
|
+
def get_metadata(self, frequency: str = 'frequencyA') -> NISARMetadata:
|
|
159
|
+
"""Extract metadata from NISAR product.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
frequency: Frequency band (default: 'frequencyA')
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
NISARMetadata object containing product metadata
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ValueError: If no polarizations found in product
|
|
169
|
+
"""
|
|
170
|
+
if frequency in self._metadata_cache:
|
|
171
|
+
return self._metadata_cache[frequency]
|
|
172
|
+
|
|
173
|
+
with h5py.File(self.product_path, 'r') as f:
|
|
174
|
+
science_path = self._get_science_path(frequency)
|
|
175
|
+
|
|
176
|
+
# Get polarizations
|
|
177
|
+
polarizations = self.get_available_polarizations(frequency)
|
|
178
|
+
|
|
179
|
+
if not polarizations:
|
|
180
|
+
raise ValueError(f'No polarizations found at {science_path}')
|
|
181
|
+
|
|
182
|
+
# Read first polarization to get shape
|
|
183
|
+
first_pol = polarizations[0]
|
|
184
|
+
data_path = f'{science_path}/{first_pol}'
|
|
185
|
+
dataset = f[data_path]
|
|
186
|
+
shape = dataset.shape
|
|
187
|
+
|
|
188
|
+
# Get coordinate information
|
|
189
|
+
x_coords_path = f'{science_path}/xCoordinates'
|
|
190
|
+
y_coords_path = f'{science_path}/yCoordinates'
|
|
191
|
+
|
|
192
|
+
if x_coords_path in f and y_coords_path in f:
|
|
193
|
+
x_coords = f[x_coords_path][:]
|
|
194
|
+
y_coords = f[y_coords_path][:]
|
|
195
|
+
|
|
196
|
+
x_min, x_max = float(x_coords.min()), float(x_coords.max())
|
|
197
|
+
y_min, y_max = float(y_coords.min()), float(y_coords.max())
|
|
198
|
+
|
|
199
|
+
# Calculate spacing
|
|
200
|
+
x_spacing = (x_max - x_min) / (shape[1] - 1) if shape[1] > 1 else 0
|
|
201
|
+
y_spacing = (y_max - y_min) / (shape[0] - 1) if shape[0] > 1 else 0
|
|
202
|
+
else:
|
|
203
|
+
# Fallback if coordinates not found
|
|
204
|
+
x_min = y_min = 0.0
|
|
205
|
+
x_max = float(shape[1])
|
|
206
|
+
y_max = float(shape[0])
|
|
207
|
+
x_spacing = y_spacing = 1.0
|
|
208
|
+
|
|
209
|
+
# Try to get EPSG code
|
|
210
|
+
epsg = None
|
|
211
|
+
projection_path = f'{science_path}/projection'
|
|
212
|
+
if projection_path in f:
|
|
213
|
+
proj_group = f[projection_path]
|
|
214
|
+
if 'epsg' in proj_group.attrs:
|
|
215
|
+
epsg = int(proj_group.attrs['epsg'])
|
|
216
|
+
|
|
217
|
+
metadata = NISARMetadata(
|
|
218
|
+
product_path=str(self.product_path),
|
|
219
|
+
frequency=frequency,
|
|
220
|
+
polarizations=polarizations,
|
|
221
|
+
grid_name=frequency,
|
|
222
|
+
shape=shape,
|
|
223
|
+
epsg=epsg,
|
|
224
|
+
x_spacing=x_spacing,
|
|
225
|
+
y_spacing=y_spacing,
|
|
226
|
+
x_min=x_min,
|
|
227
|
+
y_min=y_min,
|
|
228
|
+
x_max=x_max,
|
|
229
|
+
y_max=y_max
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
self._metadata_cache[frequency] = metadata
|
|
233
|
+
return metadata
|
|
234
|
+
|
|
235
|
+
def read_data(
|
|
236
|
+
self,
|
|
237
|
+
polarization: str,
|
|
238
|
+
frequency: str = 'frequencyA',
|
|
239
|
+
window: Optional[Tuple[Tuple[int, int], Tuple[int, int]]] = None
|
|
240
|
+
) -> np.ndarray:
|
|
241
|
+
"""Read SAR data for a specific polarization.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
polarization: Polarization to read (e.g., 'HH', 'HV', 'VH', 'VV')
|
|
245
|
+
frequency: Frequency band (default: 'frequencyA')
|
|
246
|
+
window: Optional window to read as ((row_start, row_stop), (col_start, col_stop))
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Complex numpy array containing SAR data
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
ValueError: If polarization not found in product
|
|
253
|
+
"""
|
|
254
|
+
with h5py.File(self.product_path, 'r') as f:
|
|
255
|
+
science_path = self._get_science_path(frequency)
|
|
256
|
+
data_path = f'{science_path}/{polarization}'
|
|
257
|
+
|
|
258
|
+
if data_path not in f:
|
|
259
|
+
raise ValueError(f'Polarization {polarization} not found at {data_path}')
|
|
260
|
+
|
|
261
|
+
dataset = f[data_path]
|
|
262
|
+
|
|
263
|
+
if window is not None:
|
|
264
|
+
(row_start, row_stop), (col_start, col_stop) = window
|
|
265
|
+
data = dataset[row_start:row_stop, col_start:col_stop]
|
|
266
|
+
else:
|
|
267
|
+
data = dataset[:]
|
|
268
|
+
|
|
269
|
+
return data
|
|
270
|
+
|
|
271
|
+
def get_coordinates(
|
|
272
|
+
self,
|
|
273
|
+
frequency: str = 'frequencyA'
|
|
274
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
275
|
+
"""Get X and Y coordinate arrays.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
frequency: Frequency band (default: 'frequencyA')
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Tuple of (x_coordinates, y_coordinates) as 1D arrays
|
|
282
|
+
"""
|
|
283
|
+
with h5py.File(self.product_path, 'r') as f:
|
|
284
|
+
science_path = self._get_science_path(frequency)
|
|
285
|
+
|
|
286
|
+
x_coords = f[f'{science_path}/xCoordinates'][:]
|
|
287
|
+
y_coords = f[f'{science_path}/yCoordinates'][:]
|
|
288
|
+
|
|
289
|
+
return x_coords, y_coords
|
|
290
|
+
|
|
291
|
+
def info(self) -> Dict:
|
|
292
|
+
"""Get comprehensive product information.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Dictionary containing product information
|
|
296
|
+
"""
|
|
297
|
+
metadata = self.get_metadata()
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
'product_path': metadata.product_path,
|
|
301
|
+
'frequency': metadata.frequency,
|
|
302
|
+
'polarizations': metadata.polarizations,
|
|
303
|
+
'grid': metadata.grid_name,
|
|
304
|
+
'shape': metadata.shape,
|
|
305
|
+
'epsg': metadata.epsg,
|
|
306
|
+
'bounds': metadata.bounds,
|
|
307
|
+
'x_spacing': metadata.x_spacing,
|
|
308
|
+
'y_spacing': metadata.y_spacing,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class NISARCutter:
|
|
313
|
+
"""Cut/subset NISAR products based on WKT polygons.
|
|
314
|
+
|
|
315
|
+
This class provides functionality to subset NISAR GSLC products
|
|
316
|
+
based on WKT polygon geometries, preserving georeferencing information.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
reader: NISARReader instance
|
|
320
|
+
|
|
321
|
+
Example:
|
|
322
|
+
>>> reader = NISARReader('product.h5')
|
|
323
|
+
>>> cutter = NISARCutter(reader)
|
|
324
|
+
>>> wkt_polygon = 'POLYGON((lon1 lat1, lon2 lat2, ...))'
|
|
325
|
+
>>> subset_data = cutter.cut_by_wkt(wkt_polygon, 'HH')
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
def __init__(self, reader: NISARReader):
|
|
329
|
+
"""Initialize cutter with a NISAR reader."""
|
|
330
|
+
self.reader = reader
|
|
331
|
+
|
|
332
|
+
def _wkt_to_polygon(self, wkt_string: str) -> Polygon:
|
|
333
|
+
"""Convert WKT string to Shapely Polygon.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
wkt_string: Well-Known Text polygon string
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Shapely Polygon object
|
|
340
|
+
"""
|
|
341
|
+
geom = wkt.loads(wkt_string)
|
|
342
|
+
if not isinstance(geom, Polygon):
|
|
343
|
+
raise ValueError(f'WKT geometry must be a Polygon, got {type(geom).__name__}')
|
|
344
|
+
return geom
|
|
345
|
+
|
|
346
|
+
def _get_pixel_window(
|
|
347
|
+
self,
|
|
348
|
+
polygon: Polygon,
|
|
349
|
+
metadata: NISARMetadata
|
|
350
|
+
) -> Tuple[Tuple[int, int], Tuple[int, int]]:
|
|
351
|
+
"""Calculate pixel window from polygon bounds.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
polygon: Shapely Polygon defining the area of interest
|
|
355
|
+
metadata: NISAR product metadata
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Window as ((row_start, row_stop), (col_start, col_stop))
|
|
359
|
+
"""
|
|
360
|
+
# Get polygon bounds
|
|
361
|
+
minx, miny, maxx, maxy = polygon.bounds
|
|
362
|
+
|
|
363
|
+
# Convert to pixel coordinates
|
|
364
|
+
transform = metadata.transform
|
|
365
|
+
inv_transform = ~transform
|
|
366
|
+
|
|
367
|
+
# Transform polygon bounds to pixel space
|
|
368
|
+
col_min, row_max = inv_transform * (minx, miny)
|
|
369
|
+
col_max, row_min = inv_transform * (maxx, maxy)
|
|
370
|
+
|
|
371
|
+
# Clip to image bounds and ensure integer coordinates
|
|
372
|
+
row_start = max(0, int(np.floor(row_min)))
|
|
373
|
+
row_stop = min(metadata.shape[0], int(np.ceil(row_max)))
|
|
374
|
+
col_start = max(0, int(np.floor(col_min)))
|
|
375
|
+
col_stop = min(metadata.shape[1], int(np.ceil(col_max)))
|
|
376
|
+
|
|
377
|
+
# Ensure valid window
|
|
378
|
+
if row_start >= row_stop or col_start >= col_stop:
|
|
379
|
+
raise ValueError(f'Polygon does not intersect with product bounds: {metadata.bounds}')
|
|
380
|
+
|
|
381
|
+
return ((row_start, row_stop), (col_start, col_stop))
|
|
382
|
+
|
|
383
|
+
def _create_mask(
|
|
384
|
+
self,
|
|
385
|
+
polygon: Polygon,
|
|
386
|
+
window: Tuple[Tuple[int, int], Tuple[int, int]],
|
|
387
|
+
metadata: NISARMetadata
|
|
388
|
+
) -> np.ndarray:
|
|
389
|
+
"""Create binary mask for polygon within window.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
polygon: Shapely Polygon defining the area of interest
|
|
393
|
+
window: Pixel window as ((row_start, row_stop), (col_start, col_stop))
|
|
394
|
+
metadata: NISAR product metadata
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
Boolean mask array
|
|
398
|
+
"""
|
|
399
|
+
(row_start, row_stop), (col_start, col_stop) = window
|
|
400
|
+
rows = row_stop - row_start
|
|
401
|
+
cols = col_stop - col_start
|
|
402
|
+
|
|
403
|
+
# Create coordinate grids for the window
|
|
404
|
+
transform = metadata.transform
|
|
405
|
+
|
|
406
|
+
# Create mesh grid of pixel coordinates
|
|
407
|
+
y_indices = np.arange(row_start, row_stop)
|
|
408
|
+
x_indices = np.arange(col_start, col_stop)
|
|
409
|
+
|
|
410
|
+
# Transform pixel coordinates to geographic coordinates
|
|
411
|
+
xx, yy = np.meshgrid(x_indices, y_indices)
|
|
412
|
+
|
|
413
|
+
# Apply affine transform
|
|
414
|
+
x_coords = transform.c + xx * transform.a + yy * transform.b
|
|
415
|
+
y_coords = transform.f + xx * transform.d + yy * transform.e
|
|
416
|
+
|
|
417
|
+
# Create mask by checking point inclusion in polygon
|
|
418
|
+
mask = np.zeros((rows, cols), dtype=bool)
|
|
419
|
+
|
|
420
|
+
# Vectorized point-in-polygon test
|
|
421
|
+
from shapely.vectorized import contains
|
|
422
|
+
mask = contains(polygon, x_coords.ravel(), y_coords.ravel()).reshape((rows, cols))
|
|
423
|
+
|
|
424
|
+
return mask
|
|
425
|
+
|
|
426
|
+
def cut_by_wkt(
|
|
427
|
+
self,
|
|
428
|
+
wkt_polygon: str,
|
|
429
|
+
polarization: str,
|
|
430
|
+
frequency: str = 'frequencyA',
|
|
431
|
+
apply_mask: bool = True
|
|
432
|
+
) -> Dict[str, Union[np.ndarray, NISARMetadata, Affine]]:
|
|
433
|
+
"""Cut NISAR product by WKT polygon.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
wkt_polygon: Well-Known Text polygon string defining AOI
|
|
437
|
+
polarization: Polarization to read (e.g., 'HH', 'HV', 'VH', 'VV')
|
|
438
|
+
frequency: Frequency band (default: 'frequencyA')
|
|
439
|
+
apply_mask: If True, apply polygon mask to data (pixels outside polygon set to NaN)
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
Dictionary containing:
|
|
443
|
+
- 'data': Subset SAR data array
|
|
444
|
+
- 'mask': Boolean mask (True inside polygon)
|
|
445
|
+
- 'metadata': Updated metadata for subset
|
|
446
|
+
- 'transform': Affine transform for subset
|
|
447
|
+
- 'window': Pixel window used for extraction
|
|
448
|
+
- 'polygon': Original polygon geometry
|
|
449
|
+
"""
|
|
450
|
+
# Parse WKT polygon
|
|
451
|
+
polygon = self._wkt_to_polygon(wkt_polygon)
|
|
452
|
+
|
|
453
|
+
# Get product metadata
|
|
454
|
+
metadata = self.reader.get_metadata(frequency)
|
|
455
|
+
|
|
456
|
+
# Calculate pixel window
|
|
457
|
+
window = self._get_pixel_window(polygon, metadata)
|
|
458
|
+
(row_start, row_stop), (col_start, col_stop) = window
|
|
459
|
+
|
|
460
|
+
# Read data for window
|
|
461
|
+
data = self.reader.read_data(polarization, frequency, window)
|
|
462
|
+
|
|
463
|
+
# Create mask
|
|
464
|
+
mask = self._create_mask(polygon, window, metadata)
|
|
465
|
+
|
|
466
|
+
# Apply mask if requested
|
|
467
|
+
if apply_mask:
|
|
468
|
+
# Convert to float if integer type
|
|
469
|
+
if not np.iscomplexobj(data):
|
|
470
|
+
data = data.astype(float)
|
|
471
|
+
# Create masked array or set values outside polygon to NaN
|
|
472
|
+
masked_data = data.copy()
|
|
473
|
+
if np.iscomplexobj(data):
|
|
474
|
+
masked_data[~mask] = np.nan + 1j * np.nan
|
|
475
|
+
else:
|
|
476
|
+
masked_data[~mask] = np.nan
|
|
477
|
+
data = masked_data
|
|
478
|
+
|
|
479
|
+
# Update transform for subset
|
|
480
|
+
transform = metadata.transform
|
|
481
|
+
subset_transform = transform * Affine.translation(col_start, row_start)
|
|
482
|
+
|
|
483
|
+
# Calculate new bounds
|
|
484
|
+
subset_x_min = metadata.x_min + col_start * metadata.x_spacing
|
|
485
|
+
subset_y_max = metadata.y_max - row_start * abs(metadata.y_spacing)
|
|
486
|
+
subset_x_max = metadata.x_min + col_stop * metadata.x_spacing
|
|
487
|
+
subset_y_min = metadata.y_max - row_stop * abs(metadata.y_spacing)
|
|
488
|
+
|
|
489
|
+
# Create updated metadata for subset
|
|
490
|
+
subset_metadata = NISARMetadata(
|
|
491
|
+
product_path=metadata.product_path,
|
|
492
|
+
frequency=frequency,
|
|
493
|
+
polarizations=metadata.polarizations,
|
|
494
|
+
grid_name=metadata.grid_name,
|
|
495
|
+
shape=data.shape,
|
|
496
|
+
epsg=metadata.epsg,
|
|
497
|
+
x_spacing=metadata.x_spacing,
|
|
498
|
+
y_spacing=metadata.y_spacing,
|
|
499
|
+
x_min=subset_x_min,
|
|
500
|
+
y_min=subset_y_min,
|
|
501
|
+
x_max=subset_x_max,
|
|
502
|
+
y_max=subset_y_max
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
'data': data,
|
|
507
|
+
'mask': mask,
|
|
508
|
+
'metadata': subset_metadata,
|
|
509
|
+
'transform': subset_transform,
|
|
510
|
+
'window': window,
|
|
511
|
+
'polygon': polygon
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
def save_subset(
|
|
515
|
+
self,
|
|
516
|
+
subset_result: Dict,
|
|
517
|
+
output_path: Union[str, Path],
|
|
518
|
+
driver: str = 'GTiff',
|
|
519
|
+
**kwargs
|
|
520
|
+
) -> None:
|
|
521
|
+
"""Save subset result to a georeferenced file.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
subset_result: Result dictionary from cut_by_wkt()
|
|
525
|
+
output_path: Output file path
|
|
526
|
+
driver: Rasterio driver (default: 'GTiff') or 'HDF5'/'h5' for HDF5 format
|
|
527
|
+
**kwargs: Additional arguments passed to rasterio.open() or h5py.File.create_dataset()
|
|
528
|
+
"""
|
|
529
|
+
data = subset_result['data']
|
|
530
|
+
metadata = subset_result['metadata']
|
|
531
|
+
transform = subset_result['transform']
|
|
532
|
+
output_path = Path(output_path)
|
|
533
|
+
|
|
534
|
+
# Check if HDF5 format is requested
|
|
535
|
+
if driver.upper() in ['HDF5', 'H5']:
|
|
536
|
+
self._save_subset_hdf5(subset_result, output_path, **kwargs)
|
|
537
|
+
else:
|
|
538
|
+
self._save_subset_rasterio(subset_result, output_path, driver, **kwargs)
|
|
539
|
+
|
|
540
|
+
print(f'Subset saved to: {output_path}')
|
|
541
|
+
|
|
542
|
+
def _save_subset_hdf5(
|
|
543
|
+
self,
|
|
544
|
+
subset_result: Dict,
|
|
545
|
+
output_path: Path,
|
|
546
|
+
**kwargs
|
|
547
|
+
) -> None:
|
|
548
|
+
"""Save subset result to HDF5 file.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
subset_result: Result dictionary from cut_by_wkt()
|
|
552
|
+
output_path: Output file path
|
|
553
|
+
**kwargs: Additional arguments passed to h5py.File.create_dataset()
|
|
554
|
+
"""
|
|
555
|
+
data = subset_result['data']
|
|
556
|
+
metadata = subset_result['metadata']
|
|
557
|
+
transform = subset_result['transform']
|
|
558
|
+
mask = subset_result['mask']
|
|
559
|
+
|
|
560
|
+
with h5py.File(output_path, 'w') as f:
|
|
561
|
+
# Create main data group
|
|
562
|
+
data_group = f.create_group('data')
|
|
563
|
+
|
|
564
|
+
# Save the SAR data
|
|
565
|
+
ds = data_group.create_dataset('array', data=data, **kwargs)
|
|
566
|
+
|
|
567
|
+
# Save mask
|
|
568
|
+
mask_ds = data_group.create_dataset('mask', data=mask, compression='gzip')
|
|
569
|
+
|
|
570
|
+
# Save metadata as attributes
|
|
571
|
+
meta_group = f.create_group('metadata')
|
|
572
|
+
meta_group.attrs['product_path'] = str(metadata.product_path)
|
|
573
|
+
meta_group.attrs['frequency'] = metadata.frequency
|
|
574
|
+
meta_group.attrs['grid_name'] = metadata.grid_name
|
|
575
|
+
meta_group.attrs['shape'] = metadata.shape
|
|
576
|
+
meta_group.attrs['epsg'] = metadata.epsg if metadata.epsg else -1
|
|
577
|
+
meta_group.attrs['x_spacing'] = metadata.x_spacing
|
|
578
|
+
meta_group.attrs['y_spacing'] = metadata.y_spacing
|
|
579
|
+
meta_group.attrs['x_min'] = metadata.x_min
|
|
580
|
+
meta_group.attrs['y_min'] = metadata.y_min
|
|
581
|
+
meta_group.attrs['x_max'] = metadata.x_max
|
|
582
|
+
meta_group.attrs['y_max'] = metadata.y_max
|
|
583
|
+
|
|
584
|
+
# Save transform as array
|
|
585
|
+
transform_arr = np.array([
|
|
586
|
+
transform.a, transform.b, transform.c,
|
|
587
|
+
transform.d, transform.e, transform.f
|
|
588
|
+
])
|
|
589
|
+
meta_group.create_dataset('transform', data=transform_arr)
|
|
590
|
+
|
|
591
|
+
# Save polarizations as string array
|
|
592
|
+
if metadata.polarizations:
|
|
593
|
+
dt = h5py.special_dtype(vlen=str)
|
|
594
|
+
pol_ds = meta_group.create_dataset('polarizations',
|
|
595
|
+
(len(metadata.polarizations),),
|
|
596
|
+
dtype=dt)
|
|
597
|
+
for i, pol in enumerate(metadata.polarizations):
|
|
598
|
+
pol_ds[i] = pol
|
|
599
|
+
|
|
600
|
+
def _save_subset_rasterio(
|
|
601
|
+
self,
|
|
602
|
+
subset_result: Dict,
|
|
603
|
+
output_path: Path,
|
|
604
|
+
driver: str,
|
|
605
|
+
**kwargs
|
|
606
|
+
) -> None:
|
|
607
|
+
"""Save subset result using rasterio (GeoTIFF, etc.).
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
subset_result: Result dictionary from cut_by_wkt()
|
|
611
|
+
output_path: Output file path
|
|
612
|
+
driver: Rasterio driver
|
|
613
|
+
**kwargs: Additional arguments passed to rasterio.open()
|
|
614
|
+
"""
|
|
615
|
+
data = subset_result['data']
|
|
616
|
+
metadata = subset_result['metadata']
|
|
617
|
+
transform = subset_result['transform']
|
|
618
|
+
|
|
619
|
+
# Determine data type and bands
|
|
620
|
+
if np.iscomplexobj(data):
|
|
621
|
+
# Save as two-band (real, imaginary)
|
|
622
|
+
count = 2
|
|
623
|
+
dtype = rasterio.float32
|
|
624
|
+
|
|
625
|
+
# Separate real and imaginary parts
|
|
626
|
+
real_part = np.real(data).astype(np.float32)
|
|
627
|
+
imag_part = np.imag(data).astype(np.float32)
|
|
628
|
+
|
|
629
|
+
write_data = [real_part, imag_part]
|
|
630
|
+
else:
|
|
631
|
+
count = 1
|
|
632
|
+
dtype = data.dtype
|
|
633
|
+
write_data = [data]
|
|
634
|
+
|
|
635
|
+
# Create rasterio profile
|
|
636
|
+
profile = {
|
|
637
|
+
'driver': driver,
|
|
638
|
+
'height': data.shape[0],
|
|
639
|
+
'width': data.shape[1],
|
|
640
|
+
'count': count,
|
|
641
|
+
'dtype': dtype,
|
|
642
|
+
'crs': f'EPSG:{metadata.epsg}' if metadata.epsg else None,
|
|
643
|
+
'transform': transform,
|
|
644
|
+
'nodata': np.nan
|
|
645
|
+
}
|
|
646
|
+
profile.update(kwargs)
|
|
647
|
+
|
|
648
|
+
# Write to file
|
|
649
|
+
with rasterio.open(output_path, 'w', **profile) as dst:
|
|
650
|
+
for i, band_data in enumerate(write_data, 1):
|
|
651
|
+
dst.write(band_data, i)
|
|
652
|
+
|