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.
Files changed (55) hide show
  1. lunar_toolkit/__init__.py +6 -0
  2. lunar_toolkit/asp.py +432 -0
  3. lunar_toolkit/cli/__init__.py +88 -0
  4. lunar_toolkit/cli/_jobs.py +437 -0
  5. lunar_toolkit/cli/_status.py +368 -0
  6. lunar_toolkit/cli/_utils.py +128 -0
  7. lunar_toolkit/cli/app.py +205 -0
  8. lunar_toolkit/cli/build_db.py +130 -0
  9. lunar_toolkit/cli/download.py +111 -0
  10. lunar_toolkit/cli/kaguya.py +253 -0
  11. lunar_toolkit/cli/ode_search.py +144 -0
  12. lunar_toolkit/cli/pairs.py +236 -0
  13. lunar_toolkit/cli/process.py +138 -0
  14. lunar_toolkit/cli/query.py +70 -0
  15. lunar_toolkit/cli/reference.py +233 -0
  16. lunar_toolkit/cli/search.py +90 -0
  17. lunar_toolkit/cli/serve.py +706 -0
  18. lunar_toolkit/cli/sfs.py +344 -0
  19. lunar_toolkit/cli/sfs_report.py +148 -0
  20. lunar_toolkit/cli/shadowcam.py +246 -0
  21. lunar_toolkit/cli/stereo.py +85 -0
  22. lunar_toolkit/cli/workspace.py +84 -0
  23. lunar_toolkit/config.py +345 -0
  24. lunar_toolkit/constants.py +217 -0
  25. lunar_toolkit/csm.py +340 -0
  26. lunar_toolkit/downloader.py +447 -0
  27. lunar_toolkit/geometry.py +556 -0
  28. lunar_toolkit/isis.py +222 -0
  29. lunar_toolkit/kaguya/__init__.py +45 -0
  30. lunar_toolkit/kaguya/mi.py +274 -0
  31. lunar_toolkit/kaguya/tc.py +370 -0
  32. lunar_toolkit/lroc/__init__.py +6 -0
  33. lunar_toolkit/lroc/cumindex.py +1197 -0
  34. lunar_toolkit/lroc/nac.py +702 -0
  35. lunar_toolkit/observation.py +127 -0
  36. lunar_toolkit/ode.py +485 -0
  37. lunar_toolkit/provenance.py +312 -0
  38. lunar_toolkit/reference.py +1111 -0
  39. lunar_toolkit/selection.py +714 -0
  40. lunar_toolkit/sfs.py +1534 -0
  41. lunar_toolkit/sfs_products.py +676 -0
  42. lunar_toolkit/sfs_report.py +1328 -0
  43. lunar_toolkit/shadowcam/__init__.py +29 -0
  44. lunar_toolkit/shadowcam/sc.py +392 -0
  45. lunar_toolkit/static/index.html +3259 -0
  46. lunar_toolkit/stereo.py +519 -0
  47. lunar_toolkit/stereo_dem.py +145 -0
  48. lunar_toolkit/tools.py +264 -0
  49. lunar_toolkit/workspace.py +1173 -0
  50. lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
  51. lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
  52. lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
  53. lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  54. lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
  55. lunar_toolkit-0.1.0.dev2.dist-info/top_level.txt +1 -0
@@ -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