Skip to content

Commit 18f0f4a

Browse files
authored
Merge branch 'dswx-ni-calval' into dswx_ni_masking_fast
2 parents d497753 + 1934cfe commit 18f0f4a

File tree

12 files changed

+741
-214
lines changed

12 files changed

+741
-214
lines changed

src/dswx_sar/common/_dswx_geogrid.py

Lines changed: 190 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11

22
from dataclasses import dataclass
3-
3+
from typing import Optional, Tuple
4+
import math
45
import numpy as np
56
from osgeo import osr, gdal
67

@@ -102,45 +103,194 @@ def from_geotiff(cls, geotiff_path):
102103
return cls(start_x, start_y, end_x, end_y, spacing_x, spacing_y,
103104
length, width, epsg)
104105

105-
def update_geogrid(self, geotiff_path):
106+
def update_geogrid(self, geotiff_path: str) -> None:
107+
"""
108+
Update this geogrid to be the union of itself and the geogrid from geotiff_path.
109+
Works for both north-up rasters (spacing_y < 0) and south-up (spacing_y > 0).
110+
111+
Conventions:
112+
- We union using true bounds (xmin/xmax/ymin/ymax) independent of sign.
113+
- We keep this object's spacing sign if already set; otherwise adopt new's.
114+
- start_x/start_y represent the "origin corner" implied by spacing signs:
115+
* spacing_x > 0 => start_x is xmin
116+
* spacing_x < 0 => start_x is xmax
117+
* spacing_y < 0 => start_y is ymax (north-up)
118+
* spacing_y > 0 => start_y is ymin (south-up)
119+
"""
120+
new = DSWXGeogrid.from_geotiff(geotiff_path)
121+
122+
# EPSG check
123+
if (not np.isnan(self.epsg)) and int(self.epsg) != int(new.epsg):
124+
raise ValueError(
125+
f"EPSG codes do not match: existing={self.epsg}, new={new.epsg}"
126+
)
127+
128+
# Decide spacings (magnitude + sign)
129+
# If self spacing is unset, inherit from new; otherwise keep self.
130+
if np.isnan(self.spacing_x):
131+
self.spacing_x = new.spacing_x
132+
if np.isnan(self.spacing_y):
133+
self.spacing_y = new.spacing_y
134+
135+
# Helper: bounds independent of sign
136+
def _bounds(g):
137+
xmin = min(g.start_x, g.end_x)
138+
xmax = max(g.start_x, g.end_x)
139+
ymin = min(g.start_y, g.end_y)
140+
ymax = max(g.start_y, g.end_y)
141+
return xmin, ymin, xmax, ymax
142+
143+
# Union bounds (handle NaNs gracefully)
144+
xmin0, ymin0, xmax0, ymax0 = _bounds(self) if not np.isnan(self.start_x) else (np.nan, np.nan, np.nan, np.nan)
145+
xmin1, ymin1, xmax1, ymax1 = _bounds(new)
146+
147+
xs = [v for v in (xmin0, xmin1) if not np.isnan(v)]
148+
ys = [v for v in (ymin0, ymin1) if not np.isnan(v)]
149+
xe = [v for v in (xmax0, xmax1) if not np.isnan(v)]
150+
ye = [v for v in (ymax0, ymax1) if not np.isnan(v)]
151+
152+
xmin_u = min(xs)
153+
ymin_u = min(ys)
154+
xmax_u = max(xe)
155+
ymax_u = max(ye)
156+
157+
# Reconstruct start/end consistent with spacing sign convention
158+
sx = self.spacing_x
159+
sy = self.spacing_y
160+
161+
# X
162+
if sx >= 0:
163+
self.start_x = xmin_u
164+
self.end_x = xmax_u
165+
else:
166+
self.start_x = xmax_u
167+
self.end_x = xmin_u
168+
169+
# Y
170+
if sy < 0: # north-up
171+
self.start_y = ymax_u
172+
self.end_y = ymin_u
173+
else: # south-up or unusual
174+
self.start_y = ymin_u
175+
self.end_y = ymax_u
176+
177+
# width/length (ensure positive)
178+
if not np.isnan(sx) and sx != 0:
179+
self.width = int(round((self.end_x - self.start_x) / sx))
180+
self.width = abs(self.width)
181+
182+
if not np.isnan(sy) and sy != 0:
183+
self.length = int(round((self.end_y - self.start_y) / sy))
184+
self.length = abs(self.length)
185+
186+
# EPSG
187+
if np.isnan(self.epsg):
188+
self.epsg = new.epsg
189+
190+
191+
@staticmethod
192+
def _snap_bounds_to_spacing(
193+
xmin: float, ymin: float, xmax: float, ymax: float,
194+
spacing_x: float, spacing_y: float,
195+
) -> Tuple[float, float, float, float]:
196+
"""
197+
Snap bounds to pixel grid so width/length become integers.
198+
Uses spacing magnitude; preserves original spacing sign when writing geogrid.
199+
"""
200+
sx = abs(spacing_x)
201+
sy = abs(spacing_y)
202+
if sx == 0 or sy == 0 or math.isnan(sx) or math.isnan(sy):
203+
raise ValueError("Invalid spacing for snapping bounds.")
204+
205+
# snap outward (cover the requested bbox)
206+
xmin_s = math.floor(xmin / sx) * sx
207+
ymin_s = math.floor(ymin / sy) * sy
208+
xmax_s = math.ceil(xmax / sx) * sx
209+
ymax_s = math.ceil(ymax / sy) * sy
210+
return xmin_s, ymin_s, xmax_s, ymax_s
211+
212+
def clip_to_bbox(
213+
self,
214+
bbox: Tuple[float, float, float, float],
215+
bbox_epsg: Optional[int] = None,
216+
snap: bool = True,
217+
snap_outward: bool = True, # keep outward snap as default
218+
) -> None:
106219
"""
107-
Update the existing geographical grid parameters based on a new
108-
GeoTIFF file, extending the grid to encompass both the existing
109-
and new grid areas.
220+
Intersect current geogrid with bbox (must be in same epsg).
221+
Updates start/end/width/length to the intersection.
110222
"""
111-
new_geogrid = DSWXGeogrid.from_geotiff(geotiff_path)
112-
113-
if self.epsg != new_geogrid.epsg and not np.isnan(self.epsg):
114-
raise ValueError("EPSG codes of the existing and "
115-
"new geogrids do not match.")
116-
self.start_x = min(filter(lambda x: not np.isnan(x),
117-
[self.start_x, new_geogrid.start_x]))
118-
self.end_x = max(filter(lambda x: not np.isnan(x),
119-
[self.end_x, new_geogrid.end_x]))
120-
121-
if self.spacing_y > 0 or np.isnan(self.spacing_y):
122-
self.end_y = max(filter(lambda x: not np.isnan(x),
123-
[self.end_y, new_geogrid.end_y]))
124-
self.start_y = min(filter(lambda x: not np.isnan(x),
125-
[self.start_y, new_geogrid.start_y]))
223+
xmin_b, ymin_b, xmax_b, ymax_b = bbox
224+
225+
epsg_ok = not (self.epsg is None or (isinstance(self.epsg, float) and np.isnan(self.epsg)))
226+
if bbox_epsg is not None and epsg_ok and int(self.epsg) != int(bbox_epsg):
227+
raise ValueError(f"EPSG mismatch: geogrid epsg={self.epsg}, bbox epsg={bbox_epsg}. "
228+
"Reproject bbox first or force EPSG earlier.")
229+
230+
xmin_g, ymin_g, xmax_g, ymax_g = self.bounds()
231+
232+
ixmin = max(xmin_g, xmin_b)
233+
iymin = max(ymin_g, ymin_b)
234+
ixmax = min(xmax_g, xmax_b)
235+
iymax = min(ymax_g, ymax_b)
236+
237+
if ixmin >= ixmax or iymin >= iymax:
238+
if ixmin >= ixmax:
239+
print('error x', ixmin, ixmax)
240+
if iymin >= iymax:
241+
print('error y', iymin, iymax)
242+
raise ValueError("DB bbox does not intersect the data extent (geogrid).")
243+
244+
if snap:
245+
# snap outward so you don't accidentally clip requested area by <1 pixel
246+
ixmin, iymin, ixmax, iymax = self._snap_bounds_to_spacing(
247+
ixmin, iymin, ixmax, iymax, self.spacing_x, self.spacing_y
248+
)
249+
250+
# Re-apply sign convention used in your geogrid:
251+
# spacing_x typically +, spacing_y may be negative.
252+
sx = self.spacing_x
253+
sy = self.spacing_y
254+
255+
# start_x should be left edge
256+
self.start_x = ixmin
257+
self.end_x = ixmax
258+
259+
# start_y should match your spacing_y sign convention:
260+
# If sy < 0, start_y is the TOP (max y). If sy > 0, start_y is BOTTOM (min y).
261+
if sy < 0:
262+
self.start_y = iymax
263+
self.end_y = iymin
126264
else:
127-
self.start_y = max(filter(lambda x: not np.isnan(x),
128-
[self.start_y, new_geogrid.start_y]))
129-
self.end_y = min(filter(lambda x: not np.isnan(x),
130-
[self.end_y, new_geogrid.end_y]))
131-
132-
self.spacing_x = new_geogrid.spacing_x \
133-
if not np.isnan(new_geogrid.spacing_x) else self.spacing_x
134-
self.spacing_y = new_geogrid.spacing_y \
135-
if not np.isnan(new_geogrid.spacing_y) else self.spacing_y
136-
137-
if not np.isnan(self.start_x) and not np.isnan(self.end_x) and \
138-
not np.isnan(self.spacing_x):
139-
self.width = int((self.end_x - self.start_x) / self.spacing_x)
140-
141-
if not np.isnan(self.start_y) and not np.isnan(self.end_y) and \
142-
not np.isnan(self.spacing_y):
143-
self.length = int((self.end_y - self.start_y) / self.spacing_y)
144-
145-
self.epsg = new_geogrid.epsg \
146-
if not np.isnan(new_geogrid.epsg) else self.epsg
265+
self.start_y = iymin
266+
self.end_y = iymax
267+
268+
self.width = int(round((self.end_x - self.start_x) / self.spacing_x)) if sx != 0 else self.width
269+
self.length = int(round((self.end_y - self.start_y) / self.spacing_y)) if sy != 0 else self.length
270+
271+
def bounds(self) -> Tuple[float, float, float, float]:
272+
"""
273+
Return geogrid bounds as (xmin, ymin, xmax, ymax) in the geogrid EPSG.
274+
Works regardless of spacing_y sign (north-up geotiffs usually have spacing_y < 0).
275+
"""
276+
# Ensure initialized
277+
required = {
278+
"start_x": self.start_x,
279+
"start_y": self.start_y,
280+
"end_x": self.end_x,
281+
"end_y": self.end_y,
282+
"spacing_x": self.spacing_x,
283+
"spacing_y": self.spacing_y,
284+
}
285+
missing = [k for k, v in required.items() if v is None or (isinstance(v, float) and np.isnan(v))]
286+
if missing:
287+
raise RuntimeError(
288+
f"Geogrid not initialized; missing {missing}. "
289+
"Call update_geogrid()/from_geotiff() before clip_to_bbox()."
290+
)
291+
292+
xmin = min(self.start_x, self.end_x)
293+
xmax = max(self.start_x, self.end_x)
294+
ymin = min(self.start_y, self.end_y)
295+
ymax = max(self.start_y, self.end_y)
296+
return xmin, ymin, xmax, ymax

src/dswx_sar/common/_dswx_sar_util.py

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from osgeo import gdal, osr, ogr
1313
from pyproj import Transformer
1414
from scipy.signal import convolve2d
15+
from scipy.ndimage import uniform_filter
1516

1617
gdal.DontUseExceptions()
1718

@@ -1905,27 +1906,31 @@ def _calculate_output_bounds(geotransform,
19051906
list
19061907
Adjusted bounding box coordinates [x_min, y_min, x_max, y_max].
19071908
"""
1908-
x_min = geotransform[0]
1909-
x_max = x_min + width * geotransform[1]
1909+
gt0, gt1, gt2, gt3, gt4, gt5 = geotransform
19101910

1911-
if geotransform[5] < 0:
1912-
y_max = geotransform[3]
1913-
y_min = y_max + length * geotransform[5]
1914-
else:
1915-
y_min = geotransform[3]
1916-
y_max = y_min + length * geotransform[5]
1911+
# Corner coordinates derived from geotransform + raster size
1912+
# (no rotation assumed; if rotation exists, this needs a different approach)
1913+
x0 = gt0
1914+
y0 = gt3
1915+
x1 = gt0 + width * gt1
1916+
y1 = gt3 + length * gt5
19171917

1918-
x_diff = x_max - x_min
1919-
y_diff = y_max - y_min
1918+
xmin = min(x0, x1)
1919+
xmax = max(x0, x1)
1920+
ymin = min(y0, y1)
1921+
ymax = max(y0, y1)
19201922

1921-
if x_diff % output_spacing != 0:
1922-
x_max = x_min + (x_diff // output_spacing + 1) * output_spacing
1923+
s = float(abs(output_spacing))
1924+
if not np.isfinite(s) or s <= 0:
1925+
raise ValueError(f"Invalid output_spacing: {output_spacing}")
19231926

1924-
output_spacing = -1 * np.abs(output_spacing)
1925-
if y_diff % output_spacing != 0:
1926-
y_min = y_max + (y_diff // np.abs(output_spacing) + 1) * output_spacing
1927+
# Snap outward so the bounds fully cover the original area
1928+
xmin_s = math.floor(xmin / s) * s
1929+
ymin_s = math.floor(ymin / s) * s
1930+
xmax_s = math.ceil (xmax / s) * s
1931+
ymax_s = math.ceil (ymax / s) * s
19271932

1928-
return [x_min, y_min, x_max, y_max]
1933+
return [xmin_s, ymin_s, xmax_s, ymax_s]
19291934

19301935

19311936
def _perform_warp_in_memory(input_file,
@@ -2114,6 +2119,37 @@ def _aggregate_10m_to_30m_conv(image, ratio, normalize_flag):
21142119

21152120
return aggregated_data
21162121

2122+
2123+
def _aggregate_10m_to_30m_fast(image: np.ndarray, ratio: int, normalize_flag: bool):
2124+
"""
2125+
Fast box-sum aggregation + stride sampling.
2126+
Matches: convolve2d(image, ones, mode='same') then [ratio//2::ratio, ratio//2::ratio]
2127+
"""
2128+
# Make sure we are working in float for normalization robustness
2129+
img = image.astype(np.float32, copy=False)
2130+
2131+
# uniform_filter gives the mean; multiply by area to get sum
2132+
area = float(ratio * ratio)
2133+
summed = uniform_filter(img, size=ratio, mode="constant", cval=0.0) * area
2134+
2135+
s = ratio // 2
2136+
aggregated = summed[s::ratio, s::ratio]
2137+
2138+
if normalize_flag:
2139+
valid = (img > 0).astype(np.float32, copy=False)
2140+
count = uniform_filter(valid, size=ratio, mode="constant", cval=0.0) * area
2141+
count = count[s::ratio, s::ratio]
2142+
2143+
# avoid divide-by-zero
2144+
aggregated = np.divide(
2145+
aggregated, count,
2146+
out=np.zeros_like(aggregated, dtype=np.float32),
2147+
where=(count > 0)
2148+
)
2149+
2150+
return aggregated
2151+
2152+
21172153
def majority_element(num_list):
21182154
"""
21192155
Determine the majority element in a list

src/dswx_sar/common/_filter_SAR.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,6 @@ def tv_bregman(X: np.ndarray, **kwargs) -> np.ndarray:
358358
https://scikit-image.org/docs/stable/api/skimage.restoration.html#skimage.restoration.denoise_tv_bregman
359359
"""
360360
lamb = kwargs.get('lambda_value', -1)
361-
print('bregman', kwargs)
362361
X_db = np.log10(X, out=np.full(X.shape, np.nan), where=(~np.isnan(X)))
363362
X_db[np.isnan(X)] = -30
364363
X_db_dspkl = denoise_tv_bregman(X_db, weight=lamb)

src/dswx_sar/common/_metadata.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from dswx_sar.common._dswx_sar_util import (band_assign_value_dict,
1212
read_geotiff)
1313
from dswx_sar.common.gcov_reader import RTCReader
14+
from dswx_sar.common.read_h5_s3 import open_h5
1415

1516
# Constants
1617
UNKNOWN = 'UNKNOWN'
@@ -511,7 +512,7 @@ def collect_frame_id(h5_list):
511512
frame_id_list = []
512513
frame_path = '/science/LSAR/identification/frameNumber'
513514
for rtc_file in h5_list:
514-
with h5py.File(rtc_file) as src:
515+
with open_h5(rtc_file) as src:
515516
frame = src[frame_path][()]
516517
frame_id_list.append(frame)
517518
return list(set(frame_id_list))
@@ -543,7 +544,7 @@ def count_rfi_frames(h5_list, pol_list, rfi_likelihood_thresh):
543544
for input_idx, rtc_file in enumerate(h5_list):
544545
rfi_found_in_frame = False
545546

546-
with h5py.File(rtc_file) as src:
547+
with open_h5(rtc_file) as src:
547548
if freq_path_list not in src:
548549
# if listOfFrequencies missing, we can't evaluate; skip this file
549550
continue

0 commit comments

Comments
 (0)