Skip to content

Commit 8671e57

Browse files
Merge pull request #19 from forrestfwilliams/develop
Release v0.3.0
2 parents b7b3b77 + 525a8e5 commit 8671e57

File tree

9 files changed

+549
-311
lines changed

9 files changed

+549
-311
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,5 +240,6 @@ Sessionx.vim
240240
tags
241241
# Persistent undo
242242
[._]*.un~
243+
243244
# Data
244-
ale
245+
ale/umbra*

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/)
77
and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

9+
## [0.3.0]
10+
11+
### Changed
12+
* Changed PFA workflow to use a sublclass of the SicdSlc class
13+
14+
### Added
15+
* Cal/Val scripts for performing absolute geolocation error (ALE) assessment
16+
17+
### Fixed
18+
* Property assignment issues that caused bugs in the SicdRzdSlc class
19+
920
## [0.2.0]
1021

1122
### Added

ale/ale.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
from argparse import ArgumentParser
2+
from datetime import datetime
3+
from pathlib import Path
4+
5+
import isce3
6+
import matplotlib.pyplot as plt
7+
import numpy as np
8+
import pandas as pd
9+
import requests
10+
from lmfit import Model
11+
from osgeo import gdal, osr
12+
from pyproj import Transformer
13+
from shapely import box
14+
from shapely.geometry import Point
15+
from shapely.ops import transform
16+
17+
18+
gdal.UseExceptions()
19+
20+
21+
def gaussfit(x, y, A, x0, y0, sigma_x, sigma_y, theta):
22+
theta = np.radians(theta)
23+
sigx2 = sigma_x**2
24+
sigy2 = sigma_y**2
25+
a = np.cos(theta) ** 2 / (2 * sigx2) + np.sin(theta) ** 2 / (2 * sigy2)
26+
b = np.sin(theta) ** 2 / (2 * sigx2) + np.cos(theta) ** 2 / (2 * sigy2)
27+
c = np.sin(2 * theta) / (4 * sigx2) - np.sin(2 * theta) / (4 * sigy2)
28+
29+
expo = -a * (x - x0) ** 2 - b * (y - y0) ** 2 - 2 * c * (x - x0) * (y - y0)
30+
return A * np.exp(expo)
31+
32+
33+
def get_cr_df(bounds, epsg, date, outdir):
34+
rosamond_bounds = [-124.409591, 32.534156, -114.131211, 42.009518]
35+
rosamond_bounds = box(*rosamond_bounds)
36+
transformer = Transformer.from_crs('EPSG:4326', f'EPSG:{epsg}', always_xy=True)
37+
rosamond_bounds = transform(transformer.transform, rosamond_bounds)
38+
assert bounds.intersects(rosamond_bounds), f'Images does not intersect with Rosamond bounds {rosamond_bounds}.'
39+
date_str = date.strftime('%Y-%m-%d+%H\u0021%M')
40+
41+
crdata = outdir / f'{date_str.split("+")[0]}_crdata.csv'
42+
if not crdata.exists():
43+
res = requests.get(
44+
f'https://uavsar.jpl.nasa.gov/cgi-bin/corner-reflectors.pl?date={str(date_str)}&project=rosamond_plate_location'
45+
)
46+
crdata.write_bytes(res.content)
47+
cr_df = pd.read_csv(crdata)
48+
new_cols = {
49+
' "Corner ID"': 'ID',
50+
'Latitude (deg)': 'lat',
51+
'Longitude (deg)': 'lon',
52+
'Azimuth (deg)': 'azm',
53+
'Height Above Ellipsoid (m)': 'hgt',
54+
'Side Length (m)': 'slen',
55+
}
56+
cr_df.rename(columns=new_cols, inplace=True)
57+
cr_df.drop(columns=cr_df.keys()[-1], inplace=True)
58+
return cr_df
59+
60+
61+
def add_image_location(cr_df, epsg, x_start, y_start, x_spacing, y_spacing, bounds):
62+
blank = [np.nan] * cr_df.shape[0]
63+
blank_bool = [False] * cr_df.shape[0]
64+
cr_df = cr_df.assign(
65+
UTMx=blank,
66+
UTMy=blank,
67+
xloc=blank,
68+
yloc=blank,
69+
xloc_floats=blank,
70+
yloc_floats=blank,
71+
inPoly=blank_bool,
72+
)
73+
transformer = Transformer.from_crs('EPSG:4326', f'EPSG:{epsg}', always_xy=True)
74+
for idx, row in cr_df.iterrows():
75+
row['UTMx'], row['UTMy'] = transformer.transform(row['lon'], row['lat'])
76+
row['xloc_floats'] = (row['UTMx'] - x_start) / x_spacing
77+
row['xloc'] = int(round(row['xloc_floats']))
78+
row['yloc_floats'] = (row['UTMy'] - y_start) / y_spacing
79+
row['yloc'] = int(round(row['yloc_floats']))
80+
row['inPoly'] = bounds.intersects(Point(row['UTMx'], row['UTMy']))
81+
cr_df.iloc[idx] = row
82+
83+
cr_df = cr_df[cr_df['inPoly']]
84+
cr_df.drop('inPoly', axis=1, inplace=True)
85+
cr_df = cr_df.reset_index(drop=True)
86+
return cr_df
87+
88+
89+
def filter_valid_data(cr_df, data):
90+
cr_df = cr_df.assign(has_data=False)
91+
for idx, row in cr_df.iterrows():
92+
xloc = int(row['xloc'])
93+
yloc = int(row['yloc'])
94+
local_data = data[yloc - 2 : yloc + 2, xloc - 3 : xloc + 3]
95+
row['has_data'] = bool(~np.all(np.isnan(local_data)))
96+
cr_df.iloc[idx] = row
97+
cr_df = cr_df[cr_df['has_data']]
98+
cr_df.drop('has_data', axis=1, inplace=True)
99+
cr_df = cr_df.reset_index(drop=True)
100+
cr_df = cr_df.loc[cr_df['slen'] > 0.8].reset_index(drop=True) # excluding SWOT CRs (0.7 m as a side length)
101+
return cr_df
102+
103+
104+
def filter_orientation(cr_df, azimuth_angle):
105+
looking_east = azimuth_angle < 180
106+
if looking_east:
107+
cr_df = cr_df[(cr_df['azm'] < 200) & (cr_df['azm'] > 20)].reset_index(drop=True)
108+
else:
109+
cr_df = cr_df[cr_df['azm'] > 340].reset_index(drop=True)
110+
return cr_df
111+
112+
113+
def plot_crs_on_image(cr_df, data, outdir, fileprefix):
114+
buffer = 50
115+
min_x = cr_df['xloc'].min() - buffer
116+
max_x = cr_df['xloc'].max() + buffer
117+
min_y = cr_df['yloc'].min() - buffer
118+
max_y = cr_df['yloc'].max() + buffer
119+
120+
fig, ax = plt.subplots(figsize=(15, 7))
121+
ax.imshow(data, cmap='gray', interpolation='bilinear', vmin=0.3, vmax=1.7, origin='upper')
122+
ax.set_xlim(min_x, max_x)
123+
ax.set_ylim(min_y, max_y)
124+
ax.axis('off')
125+
126+
for sl in pd.unique(cr_df.slen):
127+
xx = cr_df.loc[cr_df['slen'] == sl]['xloc']
128+
yy = cr_df.loc[cr_df['slen'] == sl]['yloc']
129+
id = cr_df.loc[cr_df['slen'] == sl]['ID']
130+
color = {2.4384: 'blue', 4.8: 'red', 2.8: 'yellow'}.get(sl, 'green')
131+
ax.scatter(xx, yy, color=color, marker='o', facecolor='none', lw=1)
132+
for id_ann, xx_ann, yy_ann in zip(id, xx, yy):
133+
id_ann = f'{int(id_ann)} ({sl:.1f} m)'
134+
ax.annotate(id_ann, (xx_ann, yy_ann), fontsize=10, color='grey')
135+
136+
ax.set_aspect(1)
137+
ax.set_title('Corner Reflector Locations')
138+
plt.gca().invert_yaxis()
139+
fig.savefig(outdir / f'{fileprefix}_CR_locations.png', dpi=300, bbox_inches='tight')
140+
141+
142+
def calculate_ale_for_cr(point, data, outdir, fileprefix, search_window=100, oversample_factor=4):
143+
ybuff = search_window // 2
144+
xbuff = search_window // 2
145+
yrange = np.s_[int(point['yloc'] - ybuff) : int(point['yloc'] + ybuff)]
146+
xrange = np.s_[int(point['xloc'] - xbuff) : int(point['xloc'] + xbuff)]
147+
cropped_data = data[yrange, xrange]
148+
yind, xind = np.unravel_index(np.argmax(cropped_data, axis=None), cropped_data.shape)
149+
150+
center_buff = 32
151+
yind_full = int(point['yloc'] - ybuff) + yind
152+
xind_full = int(point['xloc'] - xbuff) + xind
153+
ycenter = np.s_[int(yind_full - center_buff) : int(yind_full + center_buff)]
154+
xcenter = np.s_[int(xind_full - center_buff) : int(xind_full + center_buff)]
155+
centered_data = data[ycenter, xcenter]
156+
157+
data_ovs = isce3.cal.point_target_info.oversample(centered_data, oversample_factor, baseband=True)
158+
159+
yoff2 = int(data_ovs.shape[0] / 2)
160+
xoff2 = int(data_ovs.shape[1] / 2)
161+
numpix = 8
162+
zoom_half_size = numpix * oversample_factor
163+
data_ovs_zoom = data_ovs[
164+
yoff2 - zoom_half_size : yoff2 + zoom_half_size, xoff2 - zoom_half_size : xoff2 + zoom_half_size
165+
]
166+
167+
N = numpix * 2 * oversample_factor
168+
x = np.linspace(0, numpix * 2 * oversample_factor - 1, N)
169+
y = np.linspace(0, numpix * 2 * oversample_factor - 1, N)
170+
Xg, Yg = np.meshgrid(x, y)
171+
fmodel = Model(gaussfit, independent_vars=('x', 'y'))
172+
theta = 0.1 # deg
173+
x0 = numpix * oversample_factor
174+
y0 = numpix * oversample_factor
175+
sigx = 2
176+
sigy = 5
177+
A = np.max(data_ovs_zoom)
178+
result = fmodel.fit(data_ovs_zoom, x=Xg, y=Yg, A=A, x0=x0, y0=y0, sigma_x=sigx, sigma_y=sigy, theta=theta)
179+
fit = fmodel.func(Xg, Yg, **result.best_values)
180+
181+
ypeak_ovs = result.best_values['y0'] + yoff2 - zoom_half_size
182+
ypeak_centered = ypeak_ovs / oversample_factor
183+
ypeak = ypeak_centered + yind_full - center_buff
184+
point['yloc_cr'] = ypeak
185+
186+
xpeak_ovs = result.best_values['x0'] + xoff2 - zoom_half_size
187+
xpeak_centered = xpeak_ovs / oversample_factor
188+
xpeak = xpeak_centered + xind_full - center_buff
189+
point['xloc_cr'] = xpeak
190+
191+
xreal_centered = point['xloc'] - xind_full + center_buff
192+
xreal_ovs = xreal_centered * oversample_factor
193+
194+
yreal_centered = point['yloc'] - yind_full + center_buff
195+
yreal_ovs = yreal_centered * oversample_factor
196+
197+
plt.rcParams.update({'font.size': 14})
198+
fig, ax = plt.subplots(1, 3, figsize=(15, 7))
199+
ax[0].imshow(centered_data, cmap='gray', interpolation=None, origin='upper')
200+
ax[0].plot(xpeak_centered, ypeak_centered, 'r+', label='Return Peak')
201+
ax[0].plot(xreal_centered, yreal_centered, 'b+', label='CR Location')
202+
ax[0].legend()
203+
ax[0].set_title(f'Corner Reflector ID: {int(point["ID"])}')
204+
ax[1].imshow(data_ovs, cmap='gray', interpolation=None, origin='upper')
205+
ax[1].plot(xpeak_ovs, ypeak_ovs, 'r+')
206+
ax[1].plot(xreal_ovs, yreal_ovs, 'b+')
207+
ax[1].set_title(f'Oversampled Corner Reflector ID: {point["ID"]}')
208+
ax[2].imshow(fit, cmap='gray', interpolation=None, origin='upper')
209+
ax[2].plot(result.best_values['x0'], result.best_values['y0'], 'r+')
210+
ax[2].set_title(f'Gaussian Fit Corner Reflector ID: {int(point["ID"])}')
211+
[axi.axis('off') for axi in ax]
212+
fig.tight_layout()
213+
fig.savefig(outdir / f'{fileprefix}_CR_{int(point["ID"])}.png', dpi=300, bbox_inches='tight')
214+
215+
return point
216+
217+
218+
def cr_mean(data):
219+
return np.round(np.nanmean(data), 3)
220+
221+
222+
def cr_spread(data):
223+
return np.round(np.nanstd(data) / np.sqrt(np.size(data)), 3)
224+
225+
226+
def plot_ale(cr_df, azmangle, outdir, fileprefix):
227+
east_ale = cr_df['easting_ale']
228+
north_ale = cr_df['northing_ale']
229+
ale = cr_df['ale']
230+
los = np.deg2rad(90 - azmangle)
231+
fig, ax = plt.subplots(figsize=(8, 8))
232+
ax.scatter(east_ale, north_ale, s=20, c='k', alpha=0.6, marker='o')
233+
ax.annotate(
234+
'LOS',
235+
xytext=(np.cos(los) * 10, np.sin(los) * 10),
236+
xy=(0, 0),
237+
arrowprops=dict(edgecolor='darkblue', arrowstyle='<-'),
238+
color='darkblue',
239+
)
240+
ax.grid(True)
241+
ax.set_xlim(-15.25, 15.25)
242+
ax.set_ylim(-15.25, 15.25)
243+
ax.axhline(0, color='black')
244+
ax.axvline(0, color='black')
245+
east_metric = f'Easting: {cr_mean(east_ale)} +/- {cr_spread(east_ale)} m'
246+
north_metric = f'Northing: {cr_mean(north_ale)} +/- {cr_spread(north_ale)} m'
247+
overall_metric = f'Overall: {cr_mean(ale)} +/- {cr_spread(ale)} m'
248+
ax.set_title(f'{east_metric}, {north_metric}, {overall_metric}', fontsize=10)
249+
ax.set_xlabel('Easting Error (m)')
250+
ax.set_ylabel('Northing Error (m)')
251+
fig.suptitle('Absolute Location Error')
252+
plt.savefig(outdir / f'{fileprefix}_ale.png', dpi=300, bbox_inches='tight', transparent=True)
253+
254+
255+
def ale(filepath, date, azmangle, outdir, fileprefix):
256+
outdir.mkdir(parents=True, exist_ok=True)
257+
ds = gdal.Open(str(filepath))
258+
data = ds.GetRasterBand(1).ReadAsArray()
259+
geotransform = ds.GetGeoTransform()
260+
x_start = geotransform[0] + 0.5 * geotransform[1]
261+
y_start = geotransform[3] + 0.5 * geotransform[5]
262+
x_end = x_start + geotransform[1] * ds.RasterXSize
263+
y_end = y_start + geotransform[5] * ds.RasterYSize
264+
bounds = (x_start, y_start, x_end, y_end)
265+
bounds = box(*bounds)
266+
x_spacing = geotransform[1]
267+
y_spacing = geotransform[5]
268+
269+
srs = osr.SpatialReference()
270+
srs.ImportFromWkt(ds.GetProjectionRef())
271+
epsg = int(srs.GetAuthorityCode(None))
272+
cr_df = get_cr_df(bounds, epsg, date, outdir)
273+
cr_df = add_image_location(cr_df, epsg, x_start, y_start, x_spacing, y_spacing, bounds)
274+
cr_df = filter_valid_data(cr_df, data)
275+
cr_df = filter_orientation(cr_df, azmangle)
276+
cr_df = cr_df.assign(yloc_cr=np.nan, xloc_cr=np.nan)
277+
plot_crs_on_image(cr_df, data, outdir, fileprefix)
278+
for idx, cr in cr_df.iterrows():
279+
cr = calculate_ale_for_cr(cr, data, outdir, fileprefix)
280+
cr_df.iloc[idx] = cr
281+
282+
cr_df['easting_ale'] = (cr_df['xloc_cr'] - cr_df['xloc_floats']) * x_spacing
283+
cr_df['northing_ale'] = (cr_df['yloc_cr'] - cr_df['yloc_floats']) * y_spacing
284+
cr_df['ale'] = np.sqrt(cr_df['northing_ale'] ** 2 + cr_df['easting_ale'] ** 2)
285+
cr_df.to_csv(outdir / (fileprefix + '_ale.csv'), index=False)
286+
plot_ale(cr_df, azmangle, outdir, fileprefix)
287+
288+
289+
def main():
290+
"""Example:
291+
python ale.py rtc.tif 2024-12-03 DESC out --fileprefix rtc
292+
"""
293+
parser = ArgumentParser(description='Absolute Location Error Estimation.')
294+
parser.add_argument('filepath', type=str, help='Path to the file to be processed')
295+
parser.add_argument('date', type=str, help='Date of the image collection (YYYY-MM-DD)')
296+
parser.add_argument('azmangle', type=int, help='Azimuth angle of the image (clockwise from North in degrees)')
297+
parser.add_argument('outdir', type=str, help='Directory to save the results')
298+
parser.add_argument(
299+
'--fileprefix', type=str, help='Prefix for the output filenames (default: input filename)', default=None
300+
)
301+
args = parser.parse_args()
302+
args.filepath = Path(args.filepath)
303+
args.date = datetime.strptime(args.date, '%Y-%m-%d')
304+
assert 0 <= args.azmangle <= 360, f'Azimuth angle {args.azmangle} is out of range [0, 360].'
305+
args.outdir = Path(args.outdir)
306+
if args.fileprefix is None:
307+
args.fileprefix = Path(args.filepath).stem
308+
assert args.filepath.exists(), f'File {args.filepath} does not exist.'
309+
310+
ale(args.filepath, args.date, args.azmangle, args.outdir, args.fileprefix)
311+
312+
313+
if __name__ == '__main__':
314+
main()

ale/environment.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: multirtc-acc
2+
channels:
3+
- conda-forge
4+
- nodefaults
5+
dependencies:
6+
# For running
7+
- python
8+
- pip
9+
- gdal>=3.0
10+
- numpy>=1.20
11+
- isce3
12+
- requests
13+
- lxml
14+
- shapely
15+
- pyproj
16+
- pandas
17+
- matplotlib
18+
- lmfit
19+
# For static analysis
20+
- ruff

0 commit comments

Comments
 (0)