lunar-toolkit 0.1.0.dev2__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.
- lunar_toolkit/__init__.py +6 -0
- lunar_toolkit/asp.py +432 -0
- lunar_toolkit/cli/__init__.py +88 -0
- lunar_toolkit/cli/_jobs.py +437 -0
- lunar_toolkit/cli/_status.py +368 -0
- lunar_toolkit/cli/_utils.py +128 -0
- lunar_toolkit/cli/app.py +205 -0
- lunar_toolkit/cli/build_db.py +130 -0
- lunar_toolkit/cli/download.py +111 -0
- lunar_toolkit/cli/kaguya.py +253 -0
- lunar_toolkit/cli/ode_search.py +144 -0
- lunar_toolkit/cli/pairs.py +236 -0
- lunar_toolkit/cli/process.py +138 -0
- lunar_toolkit/cli/query.py +70 -0
- lunar_toolkit/cli/reference.py +233 -0
- lunar_toolkit/cli/search.py +90 -0
- lunar_toolkit/cli/serve.py +706 -0
- lunar_toolkit/cli/sfs.py +344 -0
- lunar_toolkit/cli/sfs_report.py +148 -0
- lunar_toolkit/cli/shadowcam.py +246 -0
- lunar_toolkit/cli/stereo.py +85 -0
- lunar_toolkit/cli/workspace.py +84 -0
- lunar_toolkit/config.py +345 -0
- lunar_toolkit/constants.py +217 -0
- lunar_toolkit/csm.py +340 -0
- lunar_toolkit/downloader.py +447 -0
- lunar_toolkit/geometry.py +556 -0
- lunar_toolkit/isis.py +222 -0
- lunar_toolkit/kaguya/__init__.py +45 -0
- lunar_toolkit/kaguya/mi.py +274 -0
- lunar_toolkit/kaguya/tc.py +370 -0
- lunar_toolkit/lroc/__init__.py +6 -0
- lunar_toolkit/lroc/cumindex.py +1197 -0
- lunar_toolkit/lroc/nac.py +702 -0
- lunar_toolkit/observation.py +127 -0
- lunar_toolkit/ode.py +485 -0
- lunar_toolkit/provenance.py +312 -0
- lunar_toolkit/reference.py +1111 -0
- lunar_toolkit/selection.py +714 -0
- lunar_toolkit/sfs.py +1534 -0
- lunar_toolkit/sfs_products.py +676 -0
- lunar_toolkit/sfs_report.py +1328 -0
- lunar_toolkit/shadowcam/__init__.py +29 -0
- lunar_toolkit/shadowcam/sc.py +392 -0
- lunar_toolkit/static/index.html +3259 -0
- lunar_toolkit/stereo.py +519 -0
- lunar_toolkit/stereo_dem.py +145 -0
- lunar_toolkit/tools.py +264 -0
- lunar_toolkit/workspace.py +1173 -0
- lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
- lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
- lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
- lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
- lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
- lunar_toolkit-0.1.0.dev2.dist-info/top_level.txt +1 -0
lunar_toolkit/stereo.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""Stereo pair ranking by convergence, illumination similarity and overlap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .constants import MOON_RADIUS_KM, parse_nac_id
|
|
11
|
+
from .geometry import AoiLike, Ring, aoi_geometry, footprint_geometry, footprint_rings, load_aoi
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
DEFAULT_WEIGHTS: dict[str, float] = {
|
|
17
|
+
"convergence": 0.35,
|
|
18
|
+
"overlap": 0.25,
|
|
19
|
+
"incidence": 0.20,
|
|
20
|
+
"azimuth": 0.10,
|
|
21
|
+
"resolution": 0.10,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
PAIR_COLUMNS: list[str] = [
|
|
25
|
+
"index_1",
|
|
26
|
+
"index_2",
|
|
27
|
+
"product_id_1",
|
|
28
|
+
"product_id_2",
|
|
29
|
+
"instrument",
|
|
30
|
+
"start_time_1",
|
|
31
|
+
"start_time_2",
|
|
32
|
+
"dt_days",
|
|
33
|
+
"convergence",
|
|
34
|
+
"convergence_min",
|
|
35
|
+
"convergence_max",
|
|
36
|
+
"b_h",
|
|
37
|
+
"incidence_1",
|
|
38
|
+
"incidence_2",
|
|
39
|
+
"incidence_diff",
|
|
40
|
+
"azimuth_1",
|
|
41
|
+
"azimuth_2",
|
|
42
|
+
"azimuth_diff",
|
|
43
|
+
"emission_1",
|
|
44
|
+
"emission_2",
|
|
45
|
+
"pixel_scale_1",
|
|
46
|
+
"pixel_scale_2",
|
|
47
|
+
"resolution_ratio",
|
|
48
|
+
"overlap_km2",
|
|
49
|
+
"overlap_frac",
|
|
50
|
+
"aoi_coverage",
|
|
51
|
+
"overlap_lat",
|
|
52
|
+
"overlap_lon",
|
|
53
|
+
"score",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def unit_vector(lat, lon) -> np.ndarray:
|
|
58
|
+
"""Unit vector(s) ``(..., 3)`` of planetocentric latitude / longitude (deg)."""
|
|
59
|
+
la, lo = np.radians(np.asarray(lat, float)), np.radians(np.asarray(lon, float))
|
|
60
|
+
return np.stack([np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)], axis=-1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _latlon(v: np.ndarray) -> tuple[float, float]:
|
|
64
|
+
v = v / np.linalg.norm(v)
|
|
65
|
+
return math.degrees(math.asin(max(-1.0, min(1.0, v[2])))), math.degrees(math.atan2(v[1], v[0])) % 360.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def angle_between(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
69
|
+
"""Angle (deg) between vectors along the last axis (numerically stable)."""
|
|
70
|
+
cross = np.linalg.norm(np.cross(a, b), axis=-1)
|
|
71
|
+
return np.degrees(np.arctan2(cross, np.sum(a * b, axis=-1)))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def azimuth_difference(a, b) -> np.ndarray:
|
|
75
|
+
"""Smallest circular difference (deg, 0–180) between azimuths."""
|
|
76
|
+
d = np.abs((np.asarray(a, float) - np.asarray(b, float)) % 360.0)
|
|
77
|
+
return np.minimum(d, 360.0 - d)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _rotate_onto(v: np.ndarray, a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
81
|
+
"""Apply to *v* the minimal rotation taking unit vector *a* onto *b*."""
|
|
82
|
+
k = np.cross(a, b)
|
|
83
|
+
s, c = np.linalg.norm(k), float(np.dot(a, b))
|
|
84
|
+
if s < 1e-15:
|
|
85
|
+
return v
|
|
86
|
+
k = k / s
|
|
87
|
+
return v * c + np.cross(k, v) * s + k * np.dot(k, v) * (1.0 - c)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def convergence_angle(
|
|
91
|
+
ground: tuple[float, float],
|
|
92
|
+
sc1: tuple[float, float, float],
|
|
93
|
+
sc2: tuple[float, float, float],
|
|
94
|
+
centre1: tuple[float, float] | None = None,
|
|
95
|
+
centre2: tuple[float, float] | None = None,
|
|
96
|
+
radius_km: float = MOON_RADIUS_KM,
|
|
97
|
+
) -> tuple[float, float]:
|
|
98
|
+
"""Convergence angle and base/height ratio for two views of one point.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
ground : (lat, lon)
|
|
103
|
+
Ground point (deg) on the sphere of radius *radius_km*.
|
|
104
|
+
sc1, sc2 : (sub_spacecraft_lat, sub_spacecraft_lon, altitude_km)
|
|
105
|
+
Spacecraft positions as sub-spacecraft point + altitude above the
|
|
106
|
+
sphere, at the time the image centre was acquired.
|
|
107
|
+
centre1, centre2 : (lat, lon), optional
|
|
108
|
+
Image centres. Pushbroom images (NAC, ShadowCam) are tens of km
|
|
109
|
+
long, so the spacecraft was elsewhere when it imaged a ground point
|
|
110
|
+
away from the centre. When given, each spacecraft position is moved
|
|
111
|
+
by the rotation that takes the image centre onto *ground*, i.e. the
|
|
112
|
+
look direction is held fixed in the local frame (constant pointing
|
|
113
|
+
along a circular orbit).
|
|
114
|
+
|
|
115
|
+
Returns
|
|
116
|
+
-------
|
|
117
|
+
(convergence_deg, b_h)
|
|
118
|
+
Angle between the look vectors spacecraft → ground, and the baseline
|
|
119
|
+
``|S1 - S2|`` divided by the mean slant range to the ground point.
|
|
120
|
+
"""
|
|
121
|
+
gu = unit_vector(*ground)
|
|
122
|
+
g = radius_km * gu
|
|
123
|
+
s1 = (radius_km + sc1[2]) * unit_vector(sc1[0], sc1[1])
|
|
124
|
+
s2 = (radius_km + sc2[2]) * unit_vector(sc2[0], sc2[1])
|
|
125
|
+
if centre1 is not None:
|
|
126
|
+
s1 = _rotate_onto(s1, unit_vector(*centre1), gu)
|
|
127
|
+
if centre2 is not None:
|
|
128
|
+
s2 = _rotate_onto(s2, unit_vector(*centre2), gu)
|
|
129
|
+
conv = float(angle_between(g - s1, g - s2))
|
|
130
|
+
h = 0.5 * (np.linalg.norm(s1 - g) + np.linalg.norm(s2 - g))
|
|
131
|
+
return conv, float(np.linalg.norm(s1 - s2) / h)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _rotate_onto_many(v: np.ndarray, a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
135
|
+
"""Row-wise :func:`_rotate_onto` of ``(n, 3)`` arrays."""
|
|
136
|
+
k = np.cross(a, b)
|
|
137
|
+
s = np.linalg.norm(k, axis=-1)
|
|
138
|
+
c = np.sum(a * b, axis=-1)
|
|
139
|
+
still = s < 1e-15
|
|
140
|
+
k = k / np.where(still, 1.0, s)[:, None]
|
|
141
|
+
out = (
|
|
142
|
+
v * c[:, None]
|
|
143
|
+
+ np.cross(k, v) * s[:, None]
|
|
144
|
+
+ k * np.sum(k * v, axis=-1)[:, None] * (1.0 - c)[:, None]
|
|
145
|
+
)
|
|
146
|
+
return np.where(still[:, None], v, out)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def convergence_angles(
|
|
150
|
+
ground: tuple[Any, Any],
|
|
151
|
+
sc1: tuple[Any, Any, Any],
|
|
152
|
+
sc2: tuple[Any, Any, Any],
|
|
153
|
+
centre1: tuple[Any, Any] | None = None,
|
|
154
|
+
centre2: tuple[Any, Any] | None = None,
|
|
155
|
+
radius_km: float = MOON_RADIUS_KM,
|
|
156
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
157
|
+
""":func:`convergence_angle` for arrays: each tuple element is an array over pairs.
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
(convergence_deg, b_h) : ndarrays
|
|
162
|
+
NaN where an input is NaN.
|
|
163
|
+
"""
|
|
164
|
+
gu = unit_vector(*ground).reshape(-1, 3)
|
|
165
|
+
g = radius_km * gu
|
|
166
|
+
s1 = (radius_km + np.asarray(sc1[2], float)).reshape(-1, 1) * unit_vector(sc1[0], sc1[1]).reshape(-1, 3)
|
|
167
|
+
s2 = (radius_km + np.asarray(sc2[2], float)).reshape(-1, 1) * unit_vector(sc2[0], sc2[1]).reshape(-1, 3)
|
|
168
|
+
if centre1 is not None:
|
|
169
|
+
s1 = _rotate_onto_many(s1, unit_vector(*centre1).reshape(-1, 3), gu)
|
|
170
|
+
if centre2 is not None:
|
|
171
|
+
s2 = _rotate_onto_many(s2, unit_vector(*centre2).reshape(-1, 3), gu)
|
|
172
|
+
conv = angle_between(g - s1, g - s2)
|
|
173
|
+
h = 0.5 * (np.linalg.norm(s1 - g, axis=-1) + np.linalg.norm(s2 - g, axis=-1))
|
|
174
|
+
return conv, np.linalg.norm(s1 - s2, axis=-1) / h
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TangentPlane:
|
|
178
|
+
"""Gnomonic projection (km) about a centre point; great circles → lines."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, lat: float, lon: float, radius_km: float = MOON_RADIUS_KM) -> None:
|
|
181
|
+
self.c = unit_vector(lat, lon)
|
|
182
|
+
ref = np.array([0.0, 0.0, 1.0]) if abs(self.c[2]) < 0.999 else np.array([1.0, 0.0, 0.0])
|
|
183
|
+
self.e = np.cross(ref, self.c)
|
|
184
|
+
self.e /= np.linalg.norm(self.e)
|
|
185
|
+
self.n = np.cross(self.c, self.e)
|
|
186
|
+
self.r = radius_km
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def from_vector(cls, v: np.ndarray) -> TangentPlane:
|
|
190
|
+
return cls(*_latlon(np.asarray(v, float)))
|
|
191
|
+
|
|
192
|
+
def ring(self, ring: Ring) -> list[tuple[float, float]]:
|
|
193
|
+
"""Project a lon/lat ring (deg) to plane (x, y) km (a :data:`~lunar_toolkit.geometry.Projection`)."""
|
|
194
|
+
a = np.asarray(ring, float).reshape(-1, 2)
|
|
195
|
+
v = unit_vector(a[:, 1], a[:, 0])
|
|
196
|
+
d = np.maximum(v @ self.c, 0.05) # far side (callers prefilter): clamp
|
|
197
|
+
return list(zip((self.r * (v @ self.e) / d).tolist(), (self.r * (v @ self.n) / d).tolist()))
|
|
198
|
+
|
|
199
|
+
def unproject(self, x: float, y: float) -> tuple[float, float]:
|
|
200
|
+
return _latlon(self.c + (x / self.r) * self.e + (y / self.r) * self.n)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def observation_footprints(df: pd.DataFrame) -> tuple[list, np.ndarray, np.ndarray]:
|
|
204
|
+
"""Footprint rings, centre unit vectors and angular radii (deg) of observation rows.
|
|
205
|
+
|
|
206
|
+
Rings are :func:`lunar_toolkit.geometry.footprint_rings` of each row
|
|
207
|
+
(``footprint_wkt``, else the ``min_lat``…``lon_east`` box), ``None``
|
|
208
|
+
(centre 0, radius 0) where a row has no footprint polygon.
|
|
209
|
+
"""
|
|
210
|
+
rings = [footprint_rings(r, "footprint_wkt") for r in df.to_dict("records")]
|
|
211
|
+
centres, radii = np.zeros((len(rings), 3)), np.zeros(len(rings))
|
|
212
|
+
for k, rr in enumerate(rings):
|
|
213
|
+
if rr is None or len(rr[0][0]) < 3:
|
|
214
|
+
rings[k] = None
|
|
215
|
+
continue
|
|
216
|
+
centres[k], radii[k] = _cap([poly[0] for poly in rr])
|
|
217
|
+
return rings, centres, radii
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _cap(rings: list) -> tuple[np.ndarray, float]:
|
|
221
|
+
"""Centre unit vector and angular radius (deg) of lon/lat rings' vertices."""
|
|
222
|
+
ll = np.array([p for r in rings for p in r], float)
|
|
223
|
+
v = unit_vector(ll[:, 1], ll[:, 0])
|
|
224
|
+
c = v.sum(axis=0)
|
|
225
|
+
c /= np.linalg.norm(c) or 1.0
|
|
226
|
+
return c, float(np.max(angle_between(v, c[None, :])))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def aoi_plane(aoi: AoiLike) -> tuple[TangentPlane, Any, np.ndarray, float]:
|
|
230
|
+
"""Tangent plane about an AOI, the AOI in it (km), and its centre / angular radius.
|
|
231
|
+
|
|
232
|
+
*aoi* is anything :func:`lunar_toolkit.geometry.load_aoi` accepts,
|
|
233
|
+
e.g. ``{"point": (lat, lon), "radius_km": r}``,
|
|
234
|
+
``{"lat": (s, n), "lon": (w, e)}``, a GeoJSON / WKT path or string.
|
|
235
|
+
|
|
236
|
+
Returns
|
|
237
|
+
-------
|
|
238
|
+
(TangentPlane, shapely geometry, ndarray, float)
|
|
239
|
+
"""
|
|
240
|
+
a = load_aoi(aoi)
|
|
241
|
+
centre, radius = _cap([poly[0] for poly in a.polygons])
|
|
242
|
+
plane = TangentPlane.from_vector(centre)
|
|
243
|
+
return plane, aoi_geometry(a, plane.ring), centre, radius
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _column(df: pd.DataFrame, col: str) -> np.ndarray:
|
|
247
|
+
import pandas as pd
|
|
248
|
+
|
|
249
|
+
if col not in df.columns:
|
|
250
|
+
return np.full(len(df), np.nan)
|
|
251
|
+
return pd.to_numeric(df[col], errors="coerce").to_numpy(float)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _ramp(x: np.ndarray, lo: float, ideal_lo: float, ideal_hi: float, hi: float) -> np.ndarray:
|
|
255
|
+
"""1 inside [ideal_lo, ideal_hi], linear to 0 at lo / hi."""
|
|
256
|
+
up = np.clip((x - lo) / max(ideal_lo - lo, 1e-9), 0.0, 1.0)
|
|
257
|
+
down = np.clip((hi - x) / max(hi - ideal_hi, 1e-9), 0.0, 1.0)
|
|
258
|
+
return np.minimum(up, down)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def find_stereo_pairs(
|
|
262
|
+
obs_df: pd.DataFrame,
|
|
263
|
+
aoi: AoiLike | None = None,
|
|
264
|
+
convergence: tuple[float, float] = (10.0, 60.0),
|
|
265
|
+
ideal_convergence: tuple[float, float] = (15.0, 45.0),
|
|
266
|
+
max_incidence_diff: float = 20.0,
|
|
267
|
+
max_azimuth_diff: float = 30.0,
|
|
268
|
+
min_overlap: float = 0.1,
|
|
269
|
+
min_aoi_coverage: float = 0.0,
|
|
270
|
+
max_resolution_ratio: float | None = None,
|
|
271
|
+
same_instrument: bool = True,
|
|
272
|
+
require_geometry: bool = False,
|
|
273
|
+
min_time_sep_s: float = 60.0,
|
|
274
|
+
weights: dict[str, float] | None = None,
|
|
275
|
+
) -> pd.DataFrame:
|
|
276
|
+
"""Rank candidate stereo pairs in an observation table.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
obs_df : DataFrame
|
|
281
|
+
Observations in the common schema (extra columns are ignored).
|
|
282
|
+
aoi : dict, str, Path or Aoi, optional
|
|
283
|
+
Area of interest: ``{"point": (lat, lon), "radius_km": r}``,
|
|
284
|
+
``{"lat": (s, n), "lon": (w, e)}`` or anything
|
|
285
|
+
:func:`lunar_toolkit.geometry.load_aoi` accepts (GeoJSON / WKT file
|
|
286
|
+
or string). Overlaps are then clipped to the AOI, pairs not
|
|
287
|
+
overlapping inside it are dropped and the convergence angle is
|
|
288
|
+
evaluated at the centroid of the clipped overlap.
|
|
289
|
+
convergence : (min, max)
|
|
290
|
+
Hard limits on the convergence angle (deg). Pairs without spacecraft
|
|
291
|
+
geometry are kept only if the emission bounds ``[|e1-e2|, e1+e2]``
|
|
292
|
+
(from centre emission angles, so approximate; 5° slack is allowed)
|
|
293
|
+
intersect this range and ``require_geometry`` is False.
|
|
294
|
+
ideal_convergence : (min, max)
|
|
295
|
+
Range scored 1; the score falls linearly to 0 at *convergence* limits.
|
|
296
|
+
max_incidence_diff, max_azimuth_diff : float
|
|
297
|
+
Hard limits (deg) on ``|i1 - i2|`` and the circular sub-solar azimuth
|
|
298
|
+
difference (azimuth is ignored where unknown).
|
|
299
|
+
min_overlap : float
|
|
300
|
+
Minimum overlap area as a fraction of the smaller footprint (with
|
|
301
|
+
an *aoi*: of the smaller footprint-inside-AOI).
|
|
302
|
+
min_aoi_coverage : float
|
|
303
|
+
Minimum fraction of the AOI covered by the overlap (with *aoi*).
|
|
304
|
+
max_resolution_ratio : float, optional
|
|
305
|
+
Maximum ``pixel_scale`` ratio (coarser / finer).
|
|
306
|
+
same_instrument : bool
|
|
307
|
+
Only pair images from the same ``instrument``.
|
|
308
|
+
require_geometry : bool
|
|
309
|
+
Drop pairs whose convergence angle cannot be computed.
|
|
310
|
+
min_time_sep_s : float
|
|
311
|
+
Minimum start-time separation; also, NAC LE/RE halves of one
|
|
312
|
+
observation are never paired (their convergence is ~0).
|
|
313
|
+
weights : dict, optional
|
|
314
|
+
Score weights, keys as :data:`DEFAULT_WEIGHTS`.
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
DataFrame
|
|
319
|
+
One row per pair, best first, with ``index_1`` / ``index_2`` (row
|
|
320
|
+
labels in *obs_df*), ``product_id_1`` / ``_2``, ``instrument``,
|
|
321
|
+
``start_time_1`` / ``_2``, ``dt_days``, ``convergence`` (deg, NaN if
|
|
322
|
+
unknown), ``convergence_min`` / ``convergence_max`` (emission bounds),
|
|
323
|
+
``b_h`` (baseline / mean slant range, ≈ 2 sin(convergence / 2)),
|
|
324
|
+
``incidence_1`` / ``_2``, ``incidence_diff``, ``azimuth_1`` / ``_2``, ``azimuth_diff``,
|
|
325
|
+
``emission_1`` / ``_2``, ``pixel_scale_1`` / ``_2``,
|
|
326
|
+
``resolution_ratio``, ``overlap_km2``, ``overlap_frac``,
|
|
327
|
+
``aoi_coverage`` (NaN without *aoi*), ``overlap_lat`` /
|
|
328
|
+
``overlap_lon`` (where convergence is evaluated) and ``score``.
|
|
329
|
+
|
|
330
|
+
Notes
|
|
331
|
+
-----
|
|
332
|
+
``score`` is a weighted mean of terms in [0, 1]:
|
|
333
|
+
|
|
334
|
+
* convergence — 1 inside *ideal_convergence*, linear to 0 at the
|
|
335
|
+
*convergence* limits (0.5 when the angle is unknown);
|
|
336
|
+
* overlap — ``aoi_coverage`` with an AOI, else ``overlap_frac``;
|
|
337
|
+
* incidence — ``1 - incidence_diff / max_incidence_diff``;
|
|
338
|
+
* azimuth — ``1 - azimuth_diff / max_azimuth_diff`` (0.5 if unknown);
|
|
339
|
+
* resolution — finer / coarser ``pixel_scale`` (0.5 if unknown).
|
|
340
|
+
"""
|
|
341
|
+
import pandas as pd
|
|
342
|
+
|
|
343
|
+
cols = PAIR_COLUMNS
|
|
344
|
+
if obs_df is None or len(obs_df) < 2:
|
|
345
|
+
return pd.DataFrame(columns=cols)
|
|
346
|
+
|
|
347
|
+
df = obs_df
|
|
348
|
+
labels = list(df.index)
|
|
349
|
+
n = len(df)
|
|
350
|
+
pids = (
|
|
351
|
+
df["product_id"].astype(str).to_numpy() if "product_id" in df else np.array([str(i) for i in labels])
|
|
352
|
+
)
|
|
353
|
+
keys = np.array([(parse_nac_id(p) or (p.upper(),))[0] for p in pids]) # NAC L/R halves share one
|
|
354
|
+
inst = df["instrument"].astype(str).to_numpy() if "instrument" in df else np.full(n, "")
|
|
355
|
+
t = pd.to_datetime(df["start_time"], errors="coerce") if "start_time" in df else pd.Series([pd.NaT] * n)
|
|
356
|
+
tsec = (t - pd.Timestamp("2000-01-01")).dt.total_seconds().to_numpy(float)
|
|
357
|
+
inc, emi = _column(df, "incidence"), _column(df, "emission")
|
|
358
|
+
az, ps = _column(df, "sub_solar_azimuth"), _column(df, "pixel_scale")
|
|
359
|
+
sclat, sclon = _column(df, "sub_spacecraft_lat"), _column(df, "sub_spacecraft_lon")
|
|
360
|
+
alt = _column(df, "spacecraft_altitude")
|
|
361
|
+
has_geom = ~(np.isnan(sclat) | np.isnan(sclon) | np.isnan(alt))
|
|
362
|
+
|
|
363
|
+
rings, centres, radii = observation_footprints(df)
|
|
364
|
+
valid = np.array([r is not None for r in rings], bool)
|
|
365
|
+
clat, clon = _column(df, "center_lat"), _column(df, "center_lon")
|
|
366
|
+
img_centres = centres.copy()
|
|
367
|
+
known = ~(np.isnan(clat) | np.isnan(clon))
|
|
368
|
+
img_centres[known] = unit_vector(clat[known], clon[known])
|
|
369
|
+
|
|
370
|
+
plane = None
|
|
371
|
+
clipped: list = []
|
|
372
|
+
aoi_area = np.nan
|
|
373
|
+
if aoi is not None:
|
|
374
|
+
plane, target, ac, aoi_radius = aoi_plane(aoi)
|
|
375
|
+
aoi_area = target.area
|
|
376
|
+
near = valid & (angle_between(centres, ac[None, :]) < radii + aoi_radius + 1e-6)
|
|
377
|
+
clipped = [
|
|
378
|
+
footprint_geometry(r, plane.ring).intersection(target) if near[k] else None
|
|
379
|
+
for k, r in enumerate(rings)
|
|
380
|
+
]
|
|
381
|
+
valid &= np.array([g is not None and g.area > 0 for g in clipped], bool)
|
|
382
|
+
|
|
383
|
+
out = []
|
|
384
|
+
lo_c, hi_c = convergence
|
|
385
|
+
for i in range(n - 1):
|
|
386
|
+
if not valid[i]:
|
|
387
|
+
continue
|
|
388
|
+
j = np.arange(i + 1, n)
|
|
389
|
+
m = valid[j] & (keys[j] != keys[i])
|
|
390
|
+
if same_instrument:
|
|
391
|
+
m &= inst[j] == inst[i]
|
|
392
|
+
dt = np.abs(tsec[j] - tsec[i])
|
|
393
|
+
m &= ~(dt < min_time_sep_s)
|
|
394
|
+
dinc = np.abs(inc[j] - inc[i])
|
|
395
|
+
m &= ~(dinc > max_incidence_diff)
|
|
396
|
+
daz = azimuth_difference(az[j], az[i])
|
|
397
|
+
m &= ~(daz > max_azimuth_diff)
|
|
398
|
+
cmin, cmax = np.abs(emi[j] - emi[i]), emi[j] + emi[i]
|
|
399
|
+
# Centre emissions only bound the convergence approximately: allow slack.
|
|
400
|
+
m &= ~(cmax < lo_c - 5.0) & ~(cmin > hi_c + 5.0)
|
|
401
|
+
if require_geometry:
|
|
402
|
+
m &= has_geom[j] & has_geom[i]
|
|
403
|
+
if max_resolution_ratio is not None:
|
|
404
|
+
rr = np.maximum(ps[j], ps[i]) / np.minimum(ps[j], ps[i])
|
|
405
|
+
m &= ~(rr > max_resolution_ratio)
|
|
406
|
+
sep = angle_between(centres[j], centres[i][None, :])
|
|
407
|
+
m &= sep <= radii[j] + radii[i] + 1e-6
|
|
408
|
+
for jj in j[m]:
|
|
409
|
+
rec = _pair_record(
|
|
410
|
+
i, jj, rings, centres, img_centres, plane, clipped, aoi_area, has_geom, sclat, sclon, alt
|
|
411
|
+
)
|
|
412
|
+
if rec is None:
|
|
413
|
+
continue
|
|
414
|
+
frac = rec["overlap_frac"]
|
|
415
|
+
if frac < min_overlap or (aoi is not None and rec["aoi_coverage"] < max(min_aoi_coverage, 1e-9)):
|
|
416
|
+
continue
|
|
417
|
+
c = rec["convergence"]
|
|
418
|
+
if not math.isnan(c) and not (lo_c <= c <= hi_c):
|
|
419
|
+
continue
|
|
420
|
+
rec.update(i=i, j=int(jj))
|
|
421
|
+
out.append(rec)
|
|
422
|
+
|
|
423
|
+
if not out:
|
|
424
|
+
return pd.DataFrame(columns=cols)
|
|
425
|
+
|
|
426
|
+
res = pd.DataFrame(out)
|
|
427
|
+
i_, j_ = res.pop("i").to_numpy(), res.pop("j").to_numpy()
|
|
428
|
+
res.insert(0, "index_1", [labels[k] for k in i_])
|
|
429
|
+
res.insert(1, "index_2", [labels[k] for k in j_])
|
|
430
|
+
res["product_id_1"], res["product_id_2"] = pids[i_], pids[j_]
|
|
431
|
+
res["instrument"] = inst[i_]
|
|
432
|
+
res["start_time_1"], res["start_time_2"] = t.to_numpy()[i_], t.to_numpy()[j_]
|
|
433
|
+
res["dt_days"] = np.abs(tsec[j_] - tsec[i_]) / 86400.0
|
|
434
|
+
res["convergence_min"] = np.abs(emi[j_] - emi[i_])
|
|
435
|
+
res["convergence_max"] = emi[j_] + emi[i_]
|
|
436
|
+
res["incidence_1"], res["incidence_2"] = inc[i_], inc[j_]
|
|
437
|
+
res["incidence_diff"] = np.abs(inc[j_] - inc[i_])
|
|
438
|
+
res["azimuth_1"], res["azimuth_2"] = az[i_], az[j_]
|
|
439
|
+
res["azimuth_diff"] = azimuth_difference(az[j_], az[i_])
|
|
440
|
+
res["emission_1"], res["emission_2"] = emi[i_], emi[j_]
|
|
441
|
+
res["pixel_scale_1"], res["pixel_scale_2"] = ps[i_], ps[j_]
|
|
442
|
+
res["resolution_ratio"] = np.maximum(ps[i_], ps[j_]) / np.minimum(ps[i_], ps[j_])
|
|
443
|
+
|
|
444
|
+
res["score"] = score_pairs(
|
|
445
|
+
res,
|
|
446
|
+
convergence=convergence,
|
|
447
|
+
ideal_convergence=ideal_convergence,
|
|
448
|
+
max_incidence_diff=max_incidence_diff,
|
|
449
|
+
max_azimuth_diff=max_azimuth_diff,
|
|
450
|
+
weights=weights,
|
|
451
|
+
aoi=aoi is not None,
|
|
452
|
+
)
|
|
453
|
+
return res[cols].sort_values("score", ascending=False, kind="stable").reset_index(drop=True)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def score_pairs(
|
|
457
|
+
pairs: pd.DataFrame,
|
|
458
|
+
*,
|
|
459
|
+
convergence: tuple[float, float] = (10.0, 60.0),
|
|
460
|
+
ideal_convergence: tuple[float, float] = (15.0, 45.0),
|
|
461
|
+
max_incidence_diff: float = 20.0,
|
|
462
|
+
max_azimuth_diff: float = 30.0,
|
|
463
|
+
weights: dict[str, float] | None = None,
|
|
464
|
+
aoi: bool = False,
|
|
465
|
+
) -> np.ndarray:
|
|
466
|
+
"""The :func:`find_stereo_pairs` ``score`` of pair rows (``convergence``, ``overlap_frac`` or
|
|
467
|
+
``aoi_coverage`` with *aoi*, ``incidence_diff``, ``azimuth_diff``, ``resolution_ratio``)."""
|
|
468
|
+
w = dict(DEFAULT_WEIGHTS, **(weights or {}))
|
|
469
|
+
conv = pairs["convergence"].to_numpy(float)
|
|
470
|
+
s_conv = np.where(np.isnan(conv), 0.5, _ramp(conv, convergence[0], *ideal_convergence, convergence[1]))
|
|
471
|
+
s_ovl = pairs["aoi_coverage" if aoi else "overlap_frac"].to_numpy(float)
|
|
472
|
+
s_inc = np.nan_to_num(
|
|
473
|
+
1.0 - pairs["incidence_diff"].to_numpy(float) / max(max_incidence_diff, 1e-9), nan=0.5
|
|
474
|
+
)
|
|
475
|
+
s_az = np.nan_to_num(1.0 - pairs["azimuth_diff"].to_numpy(float) / max(max_azimuth_diff, 1e-9), nan=0.5)
|
|
476
|
+
s_res = np.nan_to_num(1.0 / pairs["resolution_ratio"].to_numpy(float), nan=0.5)
|
|
477
|
+
tot = sum(w.values()) or 1.0
|
|
478
|
+
return (
|
|
479
|
+
w["convergence"] * s_conv
|
|
480
|
+
+ w["overlap"] * np.clip(s_ovl, 0, 1)
|
|
481
|
+
+ w["incidence"] * np.clip(s_inc, 0, 1)
|
|
482
|
+
+ w["azimuth"] * np.clip(s_az, 0, 1)
|
|
483
|
+
+ w["resolution"] * s_res
|
|
484
|
+
) / tot
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _pair_record(
|
|
488
|
+
i, j, rings, centres, img_centres, plane, clipped, aoi_area, has_geom, sclat, sclon, alt
|
|
489
|
+
) -> dict | None:
|
|
490
|
+
if plane is None:
|
|
491
|
+
pl = TangentPlane.from_vector(centres[i] + centres[j])
|
|
492
|
+
g1, g2 = footprint_geometry(rings[i], pl.ring), footprint_geometry(rings[j], pl.ring)
|
|
493
|
+
else:
|
|
494
|
+
pl, g1, g2 = plane, clipped[i], clipped[j] # already clipped to the AOI
|
|
495
|
+
inter = g1.intersection(g2)
|
|
496
|
+
area = inter.area
|
|
497
|
+
if area <= 0:
|
|
498
|
+
return None
|
|
499
|
+
aoi_cov = area / aoi_area if plane is not None and aoi_area > 0 else np.nan
|
|
500
|
+
c = inter.centroid
|
|
501
|
+
glat, glon = pl.unproject(c.x, c.y)
|
|
502
|
+
conv = bh = np.nan
|
|
503
|
+
if has_geom[i] and has_geom[j]:
|
|
504
|
+
conv, bh = convergence_angle(
|
|
505
|
+
(glat, glon),
|
|
506
|
+
(sclat[i], sclon[i], alt[i]),
|
|
507
|
+
(sclat[j], sclon[j], alt[j]),
|
|
508
|
+
_latlon(img_centres[i]),
|
|
509
|
+
_latlon(img_centres[j]),
|
|
510
|
+
)
|
|
511
|
+
return {
|
|
512
|
+
"convergence": conv,
|
|
513
|
+
"b_h": bh,
|
|
514
|
+
"overlap_km2": area,
|
|
515
|
+
"overlap_frac": area / max(min(g1.area, g2.area), 1e-12),
|
|
516
|
+
"aoi_coverage": aoi_cov,
|
|
517
|
+
"overlap_lat": glat,
|
|
518
|
+
"overlap_lon": glon,
|
|
519
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Stereo DEM from one LRO NAC pair with ISIS and ASP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import asp, isis
|
|
9
|
+
from .tools import reuse_existing
|
|
10
|
+
|
|
11
|
+
# ASP's recommended correlator settings for NAC stereo.
|
|
12
|
+
STEREO_DEFAULTS: dict[str, Any] = {
|
|
13
|
+
"stereo_algorithm": "asp_mgm",
|
|
14
|
+
"subpixel_mode": 9,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# bundle_adjust of a lone pair before stereo.
|
|
18
|
+
PAIR_BUNDLE_ADJUST_DEFAULTS: dict[str, Any] = {
|
|
19
|
+
"ip_per_image": 20000,
|
|
20
|
+
"min_matches": 1,
|
|
21
|
+
"camera_weight": 0,
|
|
22
|
+
"tri_weight": 0.1,
|
|
23
|
+
"tri_robust_threshold": 0.1,
|
|
24
|
+
"remove_outliers_params": "75.0 3.0 3.0 5.0",
|
|
25
|
+
"datum": "D_MOON",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def stereo_dem(
|
|
30
|
+
left: Path,
|
|
31
|
+
right: Path,
|
|
32
|
+
work_dir: Path,
|
|
33
|
+
*,
|
|
34
|
+
resolution: float = 2.0,
|
|
35
|
+
prior_dem: Path | None = None,
|
|
36
|
+
image_resolution: float = 1.0,
|
|
37
|
+
align: bool = True,
|
|
38
|
+
max_displacement: float = 200.0,
|
|
39
|
+
csm: bool = True,
|
|
40
|
+
web_spice: bool = True,
|
|
41
|
+
cameras: tuple[Path, Path] | None = None,
|
|
42
|
+
mapprojected: tuple[Path, Path] | None = None,
|
|
43
|
+
bundle_adjust_prefix: Path | None = None,
|
|
44
|
+
processes: int | None = None,
|
|
45
|
+
threads: int | None = None,
|
|
46
|
+
stereo_options: dict[str, Any] | None = None,
|
|
47
|
+
force: bool = False,
|
|
48
|
+
) -> Path:
|
|
49
|
+
"""Build a DEM from a NAC stereo pair; returns the final DEM path.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
left, right : Path
|
|
54
|
+
NAC EDR ``.IMG`` files or calibrated ``.cub`` files.
|
|
55
|
+
resolution : float
|
|
56
|
+
DEM grid spacing (m/px); about 3× the image resolution is typical.
|
|
57
|
+
prior_dem : Path, optional
|
|
58
|
+
Reference DEM: inputs are mapprojected onto it at *image_resolution*
|
|
59
|
+
before correlation, and the result is aligned to it when *align*.
|
|
60
|
+
cameras : (Path, Path), optional
|
|
61
|
+
Existing CSM cameras for *left* / *right* (with *csm*; default: written by
|
|
62
|
+
``isd_generate`` into ``<work_dir>/images``).
|
|
63
|
+
mapprojected : (Path, Path), optional
|
|
64
|
+
Existing images mapprojected onto *prior_dem* (any camera / adjustments:
|
|
65
|
+
ASP reads those from the geoheader), instead of mapprojecting here.
|
|
66
|
+
bundle_adjust_prefix : Path, optional
|
|
67
|
+
Adjustments to triangulate with, instead of bundle-adjusting the pair here.
|
|
68
|
+
"""
|
|
69
|
+
work_dir = Path(work_dir)
|
|
70
|
+
img_dir = work_dir / "images"
|
|
71
|
+
|
|
72
|
+
cubes: list[Path] = []
|
|
73
|
+
cams: list[Path] = []
|
|
74
|
+
for k, src in enumerate((Path(left), Path(right))):
|
|
75
|
+
cube, cam = isis.prepare_lronac(src, img_dir, csm=csm and not cameras, web=web_spice, force=force)
|
|
76
|
+
if csm and cameras:
|
|
77
|
+
cam = Path(cameras[k])
|
|
78
|
+
cubes.append(cube)
|
|
79
|
+
if cam:
|
|
80
|
+
cams.append(cam)
|
|
81
|
+
|
|
82
|
+
ba = Path(bundle_adjust_prefix) if bundle_adjust_prefix else work_dir / "ba" / "run"
|
|
83
|
+
if not bundle_adjust_prefix and not reuse_existing(Path(f"{ba}-final_residuals_pointmap.csv"), force):
|
|
84
|
+
asp.bundle_adjust(cubes, ba, cams, threads=threads, **PAIR_BUNDLE_ADJUST_DEFAULTS)
|
|
85
|
+
|
|
86
|
+
opts = {
|
|
87
|
+
**STEREO_DEFAULTS,
|
|
88
|
+
"processes": processes,
|
|
89
|
+
"threads": threads,
|
|
90
|
+
"bundle_adjust_prefix": ba,
|
|
91
|
+
**(stereo_options or {}),
|
|
92
|
+
}
|
|
93
|
+
out = work_dir / "stereo" / "run"
|
|
94
|
+
pc = Path(f"{out}-PC.tif")
|
|
95
|
+
pair_cams: list[Path | None] = [*cams] if cams else [None, None]
|
|
96
|
+
if not reuse_existing(pc, force):
|
|
97
|
+
if prior_dem:
|
|
98
|
+
maps = [Path(m) for m in mapprojected] if mapprojected else []
|
|
99
|
+
if not maps:
|
|
100
|
+
for cube, camera in zip(cubes, pair_cams):
|
|
101
|
+
m = work_dir / "prj" / f"{cube.name.split('.')[0]}.map.tif"
|
|
102
|
+
if not reuse_existing(m, force):
|
|
103
|
+
asp.mapproject(
|
|
104
|
+
prior_dem,
|
|
105
|
+
cube,
|
|
106
|
+
m,
|
|
107
|
+
camera,
|
|
108
|
+
tr=image_resolution,
|
|
109
|
+
bundle_adjust_prefix=ba,
|
|
110
|
+
threads=threads,
|
|
111
|
+
)
|
|
112
|
+
maps.append(m)
|
|
113
|
+
asp.stereo(
|
|
114
|
+
maps[0],
|
|
115
|
+
maps[1],
|
|
116
|
+
out,
|
|
117
|
+
*(cubes if not cams else cams),
|
|
118
|
+
dem=prior_dem,
|
|
119
|
+
alignment_method="none",
|
|
120
|
+
**opts,
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
asp.stereo(cubes[0], cubes[1], out, *pair_cams, alignment_method="local_epipolar", **opts)
|
|
124
|
+
|
|
125
|
+
dem = Path(f"{out}-DEM.tif")
|
|
126
|
+
if not reuse_existing(dem, force):
|
|
127
|
+
asp.point2dem(pc, tr=resolution, datum="D_MOON", errorimage=True)
|
|
128
|
+
if not (prior_dem and align):
|
|
129
|
+
return dem
|
|
130
|
+
|
|
131
|
+
align_prefix = work_dir / "align" / "run"
|
|
132
|
+
trans = Path(f"{align_prefix}-trans_source.tif")
|
|
133
|
+
if not reuse_existing(trans, force):
|
|
134
|
+
asp.pc_align(
|
|
135
|
+
prior_dem,
|
|
136
|
+
pc,
|
|
137
|
+
align_prefix,
|
|
138
|
+
max_displacement=max_displacement,
|
|
139
|
+
save_transformed_source_points=True,
|
|
140
|
+
highest_accuracy=True,
|
|
141
|
+
)
|
|
142
|
+
aligned = Path(f"{align_prefix}-trans_source-DEM.tif")
|
|
143
|
+
if not reuse_existing(aligned, force):
|
|
144
|
+
asp.point2dem(trans, tr=resolution, datum="D_MOON")
|
|
145
|
+
return aligned
|