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
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
"""Illumination-diverse image selection for shape-from-shading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING, Any, TypeAlias
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import shapely
|
|
11
|
+
|
|
12
|
+
from .geometry import AoiLike, footprint_geometry
|
|
13
|
+
from .stereo import angle_between, aoi_plane, azimuth_difference, observation_footprints
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
Prefer: TypeAlias = "str | Callable[[pd.DataFrame], pd.Series]"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sort_by_sun_azimuth(
|
|
24
|
+
obs_df: pd.DataFrame,
|
|
25
|
+
start: float = 0.0,
|
|
26
|
+
column: str = "sub_solar_azimuth",
|
|
27
|
+
) -> pd.DataFrame:
|
|
28
|
+
"""Sort rows by Sun azimuth, measured clockwise from *start* degrees.
|
|
29
|
+
|
|
30
|
+
Rows with an unknown azimuth go last. The index is preserved.
|
|
31
|
+
"""
|
|
32
|
+
import pandas as pd
|
|
33
|
+
|
|
34
|
+
az = pd.to_numeric(obs_df[column], errors="coerce")
|
|
35
|
+
key = (az - start) % 360.0
|
|
36
|
+
return obs_df.assign(_k=key).sort_values("_k", kind="stable", na_position="last").drop(columns="_k")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def aoi_coverage(obs_df: pd.DataFrame, aoi: AoiLike) -> pd.Series:
|
|
40
|
+
"""Fraction (0–1) of the AOI covered by each row's footprint.
|
|
41
|
+
|
|
42
|
+
*aoi* is ``{"point": (lat, lon), "radius_km": r}``,
|
|
43
|
+
``{"lat": (s, n), "lon": (w, e)}`` or anything
|
|
44
|
+
:func:`lunar_toolkit.geometry.load_aoi` accepts.
|
|
45
|
+
"""
|
|
46
|
+
import pandas as pd
|
|
47
|
+
|
|
48
|
+
geoms, target = _aoi_cover(obs_df, aoi)
|
|
49
|
+
area = target.area
|
|
50
|
+
vals = [g.intersection(target).area / area if g is not None and area > 0 else 0.0 for g in geoms]
|
|
51
|
+
return pd.Series(vals, index=obs_df.index, name="aoi_coverage", dtype=float)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _aoi_cover(obs_df: pd.DataFrame, aoi: AoiLike) -> tuple[list, Any]:
|
|
55
|
+
"""Per row the footprint in the AOI's tangent plane (``None`` if absent / disjoint), and the AOI."""
|
|
56
|
+
plane, target, centre, radius = aoi_plane(aoi)
|
|
57
|
+
rings, centres, radii = observation_footprints(obs_df)
|
|
58
|
+
near = angle_between(centres, centre[None, :]) < radii + radius + 1e-6
|
|
59
|
+
geoms = [
|
|
60
|
+
footprint_geometry(r, plane.ring) if r is not None and near[k] else None for k, r in enumerate(rings)
|
|
61
|
+
]
|
|
62
|
+
return geoms, target
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _candidates(
|
|
66
|
+
obs_df: pd.DataFrame,
|
|
67
|
+
incidence: tuple[float, float] | None,
|
|
68
|
+
max_emission: float | None,
|
|
69
|
+
aoi: AoiLike | None,
|
|
70
|
+
min_aoi_coverage: float,
|
|
71
|
+
) -> pd.DataFrame:
|
|
72
|
+
import pandas as pd
|
|
73
|
+
|
|
74
|
+
df = obs_df.copy()
|
|
75
|
+
df["sub_solar_azimuth"] = pd.to_numeric(df["sub_solar_azimuth"], errors="coerce")
|
|
76
|
+
df = df[df["sub_solar_azimuth"].notna()]
|
|
77
|
+
if incidence is not None:
|
|
78
|
+
inc = pd.to_numeric(df["incidence"], errors="coerce")
|
|
79
|
+
df = df[inc.between(min(incidence), max(incidence))]
|
|
80
|
+
if max_emission is not None:
|
|
81
|
+
emi = pd.to_numeric(df["emission"], errors="coerce")
|
|
82
|
+
df = df[~(emi > max_emission)]
|
|
83
|
+
if aoi is not None and not df.empty:
|
|
84
|
+
df["aoi_coverage"] = aoi_coverage(df, aoi)
|
|
85
|
+
df = df[(df["aoi_coverage"] > 0) & (df["aoi_coverage"] >= min_aoi_coverage)]
|
|
86
|
+
else:
|
|
87
|
+
df["aoi_coverage"] = np.nan
|
|
88
|
+
return df
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _quality(df: pd.DataFrame, prefer: Prefer) -> pd.Series:
|
|
92
|
+
"""Selection score (higher is better); see :func:`select_illumination_diverse`."""
|
|
93
|
+
import pandas as pd
|
|
94
|
+
|
|
95
|
+
if callable(prefer):
|
|
96
|
+
return pd.Series(prefer(df), index=df.index, dtype=float)
|
|
97
|
+
ps = (
|
|
98
|
+
pd.to_numeric(df["pixel_scale"], errors="coerce")
|
|
99
|
+
if "pixel_scale" in df
|
|
100
|
+
else pd.Series(np.nan, df.index)
|
|
101
|
+
)
|
|
102
|
+
res = (ps.min() / ps).fillna(0.5) if ps.notna().any() else pd.Series(0.5, index=df.index)
|
|
103
|
+
cov = df["aoi_coverage"].fillna(1.0)
|
|
104
|
+
if prefer == "balanced":
|
|
105
|
+
return cov * np.sqrt(res)
|
|
106
|
+
if prefer == "coverage":
|
|
107
|
+
return cov + 1e-3 * res
|
|
108
|
+
if prefer == "resolution":
|
|
109
|
+
return res + 1e-3 * cov
|
|
110
|
+
raise ValueError(f"prefer must be 'balanced', 'coverage', 'resolution' or a callable, not {prefer!r}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _thin_to(df: pd.DataFrame, max_images: int) -> pd.DataFrame:
|
|
114
|
+
"""Drop picks until *max_images* remain, always removing the lower-scored
|
|
115
|
+
image of the closest pair in azimuth (keeps the azimuth spread)."""
|
|
116
|
+
df = df.copy()
|
|
117
|
+
while len(df) > max_images:
|
|
118
|
+
az = df["sub_solar_azimuth"].to_numpy(float)
|
|
119
|
+
d = azimuth_difference(az[:, None], az[None, :])
|
|
120
|
+
np.fill_diagonal(d, np.inf)
|
|
121
|
+
a, b = np.unravel_index(np.argmin(d), d.shape)
|
|
122
|
+
sc = df["selection_score"].to_numpy(float)
|
|
123
|
+
drop = a if sc[a] < sc[b] or (sc[a] == sc[b] and a > b) else b
|
|
124
|
+
df = df.iloc[np.delete(np.arange(len(df)), drop)]
|
|
125
|
+
return df
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def select_illumination_diverse(
|
|
129
|
+
obs_df: pd.DataFrame,
|
|
130
|
+
azimuth_bin_deg: float = 30.0,
|
|
131
|
+
incidence: tuple[float, float] | None = None,
|
|
132
|
+
per_bin: int = 1,
|
|
133
|
+
max_images: int | None = None,
|
|
134
|
+
prefer: Prefer = "balanced",
|
|
135
|
+
aoi: AoiLike | None = None,
|
|
136
|
+
min_aoi_coverage: float = 0.0,
|
|
137
|
+
incidence_bin_deg: float | None = None,
|
|
138
|
+
max_emission: float | None = None,
|
|
139
|
+
method: str = "bins",
|
|
140
|
+
azimuth_origin: float = 0.0,
|
|
141
|
+
) -> pd.DataFrame:
|
|
142
|
+
"""Choose images spanning diverse Sun azimuths (for SfS / PSR mapping).
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
obs_df : DataFrame
|
|
147
|
+
Observations in the common schema.
|
|
148
|
+
azimuth_bin_deg : float
|
|
149
|
+
Bin width (``method="bins"``) or minimum separation
|
|
150
|
+
(``method="greedy"``) in sub-solar azimuth, degrees.
|
|
151
|
+
incidence : (min, max), optional
|
|
152
|
+
Keep only images with incidence in this range (e.g. ``(80, 95)`` at
|
|
153
|
+
the poles; SfS needs lit but low-Sun images).
|
|
154
|
+
per_bin : int
|
|
155
|
+
Images kept per bin. With ``method="greedy"``: at most this many
|
|
156
|
+
picks may lie within *azimuth_bin_deg* of each other.
|
|
157
|
+
max_images : int, optional
|
|
158
|
+
Cap on the result. Excess picks are removed by repeatedly dropping
|
|
159
|
+
the lower-scored image of the pair closest in azimuth, so the
|
|
160
|
+
azimuth spread is kept.
|
|
161
|
+
prefer : {"balanced", "coverage", "resolution"} or callable
|
|
162
|
+
How to rank images competing for a bin. With ``res`` = finest
|
|
163
|
+
``pixel_scale`` among candidates / own ``pixel_scale`` (0.5 if
|
|
164
|
+
unknown) and ``cov`` = AOI coverage fraction (1 without *aoi*):
|
|
165
|
+
``"balanced"`` = ``cov * sqrt(res)``; ``"coverage"`` ranks by
|
|
166
|
+
coverage then resolution; ``"resolution"`` by resolution then
|
|
167
|
+
coverage. A callable gets the candidate DataFrame and returns the
|
|
168
|
+
score (higher is better).
|
|
169
|
+
aoi : dict, str, Path or Aoi, optional
|
|
170
|
+
``{"point": (lat, lon), "radius_km": r}``, ``{"lat": ..., "lon": ...}``
|
|
171
|
+
or anything :func:`lunar_toolkit.geometry.load_aoi` accepts; images
|
|
172
|
+
not overlapping it are dropped.
|
|
173
|
+
min_aoi_coverage : float
|
|
174
|
+
Minimum fraction of the AOI an image must cover (with *aoi*).
|
|
175
|
+
incidence_bin_deg : float, optional
|
|
176
|
+
Also bin by incidence (``method="bins"`` only), giving azimuth ×
|
|
177
|
+
incidence cells — useful when shadow length diversity matters too.
|
|
178
|
+
max_emission : float, optional
|
|
179
|
+
Drop images with larger emission angles (off-nadir views are
|
|
180
|
+
foreshortened and complicate SfS).
|
|
181
|
+
method : {"bins", "greedy"}
|
|
182
|
+
``"bins"``: fixed bins ``[origin + k*w, origin + (k+1)*w)``, best
|
|
183
|
+
*per_bin* per bin (two picks in neighbouring bins may still be close
|
|
184
|
+
in azimuth). ``"greedy"``: candidates in descending score; accept
|
|
185
|
+
one unless *per_bin* accepted images already lie within
|
|
186
|
+
*azimuth_bin_deg* of it, so with ``per_bin=1`` all picks are at
|
|
187
|
+
least *azimuth_bin_deg* apart (the Bertone et al. criterion).
|
|
188
|
+
azimuth_origin : float
|
|
189
|
+
Start of the first azimuth bin, degrees.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
DataFrame
|
|
194
|
+
The selected rows (original index kept) sorted by
|
|
195
|
+
``sub_solar_azimuth`` (see :func:`sort_by_sun_azimuth`), with extra
|
|
196
|
+
columns ``aoi_coverage`` (NaN without *aoi*), ``azimuth_bin`` (bin
|
|
197
|
+
start, deg; NaN for greedy), ``incidence_bin`` (if binned) and
|
|
198
|
+
``selection_score``.
|
|
199
|
+
|
|
200
|
+
Notes
|
|
201
|
+
-----
|
|
202
|
+
This uses each image's single scene-centre Sun azimuth; for a large AOI
|
|
203
|
+
where per-pixel diversity matters, use :func:`select_for_coverage`.
|
|
204
|
+
"""
|
|
205
|
+
import pandas as pd
|
|
206
|
+
|
|
207
|
+
if azimuth_bin_deg <= 0 or per_bin < 1:
|
|
208
|
+
raise ValueError("azimuth_bin_deg must be > 0 and per_bin >= 1")
|
|
209
|
+
df = _candidates(obs_df, incidence, max_emission, aoi, min_aoi_coverage)
|
|
210
|
+
if df.empty:
|
|
211
|
+
return df.assign(azimuth_bin=pd.Series(dtype=float), selection_score=pd.Series(dtype=float))
|
|
212
|
+
df["selection_score"] = _quality(df, prefer)
|
|
213
|
+
df = df.sort_values("selection_score", ascending=False, kind="stable")
|
|
214
|
+
|
|
215
|
+
if method == "bins":
|
|
216
|
+
rel = (df["sub_solar_azimuth"] - azimuth_origin) % 360.0
|
|
217
|
+
df["azimuth_bin"] = (np.floor(rel / azimuth_bin_deg) * azimuth_bin_deg + azimuth_origin) % 360.0
|
|
218
|
+
keys = ["azimuth_bin"]
|
|
219
|
+
if incidence_bin_deg:
|
|
220
|
+
inc = pd.to_numeric(df["incidence"], errors="coerce")
|
|
221
|
+
df["incidence_bin"] = np.floor(inc / incidence_bin_deg) * incidence_bin_deg
|
|
222
|
+
keys.append("incidence_bin")
|
|
223
|
+
picked = df.groupby(keys, sort=False, dropna=False).head(per_bin)
|
|
224
|
+
elif method == "greedy":
|
|
225
|
+
df["azimuth_bin"] = np.nan
|
|
226
|
+
accepted: list[float] = []
|
|
227
|
+
keep = []
|
|
228
|
+
for idx, a in zip(df.index, df["sub_solar_azimuth"].to_numpy(float)):
|
|
229
|
+
near = int(np.sum(azimuth_difference(np.array(accepted), a) < azimuth_bin_deg)) if accepted else 0
|
|
230
|
+
if near < per_bin:
|
|
231
|
+
accepted.append(a)
|
|
232
|
+
keep.append(idx)
|
|
233
|
+
picked = df.loc[keep]
|
|
234
|
+
else:
|
|
235
|
+
raise ValueError(f"method must be 'bins' or 'greedy', not {method!r}")
|
|
236
|
+
|
|
237
|
+
if max_images is not None and len(picked) > max_images:
|
|
238
|
+
picked = _thin_to(picked, max_images)
|
|
239
|
+
return sort_by_sun_azimuth(picked)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _grid(target, grid_km: float | None, max_cells: int = 20000) -> tuple[np.ndarray, np.ndarray]:
|
|
243
|
+
"""Cell centres ``(x, y)`` of a regular grid over *target* that fall inside it."""
|
|
244
|
+
x0, y0, x1, y1 = target.bounds
|
|
245
|
+
if grid_km is None:
|
|
246
|
+
grid_km = max(max(x1 - x0, y1 - y0) / 100.0, 1e-3)
|
|
247
|
+
nx = max(int(math.ceil((x1 - x0) / grid_km)), 1)
|
|
248
|
+
ny = max(int(math.ceil((y1 - y0) / grid_km)), 1)
|
|
249
|
+
if nx * ny > max_cells:
|
|
250
|
+
f = math.sqrt(nx * ny / max_cells)
|
|
251
|
+
nx, ny = max(int(nx / f), 1), max(int(ny / f), 1)
|
|
252
|
+
gx, gy = np.meshgrid(
|
|
253
|
+
x0 + (np.arange(nx) + 0.5) * (x1 - x0) / nx, y0 + (np.arange(ny) + 0.5) * (y1 - y0) / ny
|
|
254
|
+
)
|
|
255
|
+
gx, gy = gx.ravel(), gy.ravel()
|
|
256
|
+
inside = shapely.intersects_xy(target, gx, gy)
|
|
257
|
+
return gx[inside], gy[inside]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def select_for_coverage(
|
|
261
|
+
obs_df: pd.DataFrame,
|
|
262
|
+
aoi: AoiLike,
|
|
263
|
+
azimuth_sep_deg: float = 30.0,
|
|
264
|
+
views_per_cell: int = 3,
|
|
265
|
+
incidence: tuple[float, float] | None = None,
|
|
266
|
+
max_images: int | None = None,
|
|
267
|
+
prefer: Prefer = "balanced",
|
|
268
|
+
max_emission: float | None = None,
|
|
269
|
+
grid_km: float | None = None,
|
|
270
|
+
min_gain: int = 1,
|
|
271
|
+
) -> pd.DataFrame:
|
|
272
|
+
"""Greedy, per-location illumination-diverse selection over an AOI.
|
|
273
|
+
|
|
274
|
+
The AOI is sampled on a grid (``grid_km`` spacing; default ~100 cells
|
|
275
|
+
across). Each grid cell keeps the Sun azimuths of the selected images
|
|
276
|
+
covering it. A candidate *helps* a cell if it covers the cell, the cell
|
|
277
|
+
has fewer than *views_per_cell* views and the candidate's azimuth is at
|
|
278
|
+
least *azimuth_sep_deg* from every azimuth already there. At each step
|
|
279
|
+
the image maximising ``helped_cells * (0.5 + 0.5 * score)`` is taken
|
|
280
|
+
(score as in :func:`select_illumination_diverse`, normalised to 0–1),
|
|
281
|
+
until no image helps *min_gain* cells or *max_images* is reached.
|
|
282
|
+
|
|
283
|
+
Returns
|
|
284
|
+
-------
|
|
285
|
+
DataFrame
|
|
286
|
+
Selected rows sorted by ``sub_solar_azimuth`` with extra columns
|
|
287
|
+
``aoi_coverage``, ``selection_score``, ``selection_order`` (0 = first
|
|
288
|
+
pick) and ``cells_gained`` (fraction of AOI cells the pick helped).
|
|
289
|
+
``attrs["view_fractions"]`` maps a view count *k* to the fraction of
|
|
290
|
+
AOI cells with exactly *k* non-redundant azimuths
|
|
291
|
+
(``views_per_cell`` = fully served), and ``attrs["grid_km"]`` /
|
|
292
|
+
``attrs["n_cells"]`` describe the grid.
|
|
293
|
+
"""
|
|
294
|
+
import pandas as pd
|
|
295
|
+
|
|
296
|
+
df = _candidates(obs_df, incidence, max_emission, aoi, 0.0)
|
|
297
|
+
if df.empty:
|
|
298
|
+
out = df.assign(
|
|
299
|
+
selection_score=pd.Series(dtype=float),
|
|
300
|
+
selection_order=pd.Series(dtype=int),
|
|
301
|
+
cells_gained=pd.Series(dtype=float),
|
|
302
|
+
)
|
|
303
|
+
out.attrs["view_fractions"] = {}
|
|
304
|
+
return out
|
|
305
|
+
score = _quality(df, prefer).to_numpy(float)
|
|
306
|
+
smax = np.nanmax(score) if np.isfinite(score).any() else 1.0
|
|
307
|
+
norm = np.nan_to_num(score / (smax or 1.0), nan=0.0)
|
|
308
|
+
df["selection_score"] = score
|
|
309
|
+
|
|
310
|
+
geoms, target = _aoi_cover(df, aoi)
|
|
311
|
+
cx, cy = _grid(target, grid_km)
|
|
312
|
+
if not len(cx):
|
|
313
|
+
raise ValueError("AOI grid is empty; pass a smaller grid_km")
|
|
314
|
+
cover = np.stack(
|
|
315
|
+
[shapely.intersects_xy(g, cx, cy) if g is not None else np.zeros(len(cx), bool) for g in geoms]
|
|
316
|
+
)
|
|
317
|
+
n_cells = len(cx)
|
|
318
|
+
az = df["sub_solar_azimuth"].to_numpy(float)
|
|
319
|
+
views = np.full((n_cells, views_per_cell), np.nan)
|
|
320
|
+
count = np.zeros(n_cells, int)
|
|
321
|
+
available = np.ones(len(df), bool)
|
|
322
|
+
order: list[int] = []
|
|
323
|
+
gained: list[float] = []
|
|
324
|
+
while available.any() and (max_images is None or len(order) < max_images):
|
|
325
|
+
best, best_val, best_mask = -1, 0.0, None
|
|
326
|
+
open_cells = count < views_per_cell
|
|
327
|
+
for k in np.flatnonzero(available):
|
|
328
|
+
m = cover[k] & open_cells
|
|
329
|
+
if not m.any():
|
|
330
|
+
continue
|
|
331
|
+
d = azimuth_difference(views[m], az[k])
|
|
332
|
+
ok = np.all(np.isnan(d) | (d >= azimuth_sep_deg), axis=1)
|
|
333
|
+
g = int(ok.sum())
|
|
334
|
+
val = g * (0.5 + 0.5 * norm[k])
|
|
335
|
+
if g >= min_gain and val > best_val:
|
|
336
|
+
best, best_val = int(k), val
|
|
337
|
+
best_mask = np.flatnonzero(m)[ok]
|
|
338
|
+
if best_mask is None:
|
|
339
|
+
break
|
|
340
|
+
views[best_mask, count[best_mask]] = az[best]
|
|
341
|
+
count[best_mask] += 1
|
|
342
|
+
available[best] = False
|
|
343
|
+
order.append(best)
|
|
344
|
+
gained.append(len(best_mask) / n_cells)
|
|
345
|
+
|
|
346
|
+
picked = df.iloc[order].copy()
|
|
347
|
+
picked["selection_order"] = np.arange(len(order))
|
|
348
|
+
picked["cells_gained"] = gained
|
|
349
|
+
out = sort_by_sun_azimuth(picked)
|
|
350
|
+
out.attrs["view_fractions"] = {int(k): float(np.mean(count == k)) for k in range(views_per_cell + 1)}
|
|
351
|
+
out.attrs["grid_km"] = float(np.sqrt(target.area / n_cells))
|
|
352
|
+
out.attrs["n_cells"] = int(n_cells)
|
|
353
|
+
return out
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def selection_distances(
|
|
357
|
+
coverage: np.ndarray,
|
|
358
|
+
azimuth: np.ndarray,
|
|
359
|
+
weights: tuple[float, float] = (1.0, 1.0),
|
|
360
|
+
chunk: int = 256,
|
|
361
|
+
) -> np.ndarray:
|
|
362
|
+
"""Image-to-image distance of Bertone et al. (2026, sec. 2) for k-medoids selection.
|
|
363
|
+
|
|
364
|
+
``d_ij = sqrt((w_a * a_ij)**2 + (w_c * c_ij)**2)`` with ``a_ij`` the
|
|
365
|
+
circular Sun azimuth (sub-solar longitude) difference and ``c_ij`` the
|
|
366
|
+
fraction of grid cells covered by exactly one of the two images, each
|
|
367
|
+
divided by its largest value over all pairs (so both span 0-1).
|
|
368
|
+
|
|
369
|
+
Parameters
|
|
370
|
+
----------
|
|
371
|
+
coverage : (n_images, n_cells) bool array
|
|
372
|
+
Per-image coverage (or lit) masks on a common grid.
|
|
373
|
+
azimuth : (n_images,) array
|
|
374
|
+
Sun azimuths, degrees (finite).
|
|
375
|
+
weights : (w_a, w_c)
|
|
376
|
+
Weights of the azimuth and coverage terms.
|
|
377
|
+
chunk : int
|
|
378
|
+
Rows per block of the coverage products (memory).
|
|
379
|
+
|
|
380
|
+
Returns
|
|
381
|
+
-------
|
|
382
|
+
(n_images, n_images) float array
|
|
383
|
+
"""
|
|
384
|
+
cov = np.asarray(coverage, bool)
|
|
385
|
+
az = np.asarray(azimuth, float)
|
|
386
|
+
n, cells = cov.shape
|
|
387
|
+
a = azimuth_difference(az[:, None], az[None, :])
|
|
388
|
+
f = cov.astype(np.float32)
|
|
389
|
+
counts = f.sum(axis=1)
|
|
390
|
+
c = np.empty((n, n))
|
|
391
|
+
for i in range(0, n, chunk):
|
|
392
|
+
inter = f[i : i + chunk] @ f.T
|
|
393
|
+
c[i : i + chunk] = (counts[i : i + chunk, None] + counts[None, :] - 2.0 * inter) / max(cells, 1)
|
|
394
|
+
np.fill_diagonal(c, 0.0)
|
|
395
|
+
a = a / np.max(a) if np.max(a) > 0 else a
|
|
396
|
+
c = c / np.max(c) if np.max(c) > 0 else c
|
|
397
|
+
return np.hypot(weights[0] * a, weights[1] * c)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _build(d: np.ndarray, k: int) -> np.ndarray:
|
|
401
|
+
"""PAM BUILD: the most central object, then each object that lowers the total distance most."""
|
|
402
|
+
med = [int(np.argmin(d.sum(axis=1)))]
|
|
403
|
+
near = d[:, med[0]].copy()
|
|
404
|
+
for _ in range(1, k):
|
|
405
|
+
gain = np.clip(near[:, None] - d, 0.0, None).sum(axis=0)
|
|
406
|
+
gain[med] = -1.0
|
|
407
|
+
c = int(np.argmax(gain))
|
|
408
|
+
med.append(c)
|
|
409
|
+
near = np.minimum(near, d[:, c])
|
|
410
|
+
return np.sort(med)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def kmedoids(
|
|
414
|
+
dist: np.ndarray,
|
|
415
|
+
k: int,
|
|
416
|
+
init: str = "build",
|
|
417
|
+
seed: int | None = 0,
|
|
418
|
+
n_init: int = 1,
|
|
419
|
+
max_iter: int = 100,
|
|
420
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
421
|
+
"""Alternating k-medoids (Park & Jun 2009) on a precomputed distance matrix.
|
|
422
|
+
|
|
423
|
+
Assign every object to its nearest medoid, move each medoid to the
|
|
424
|
+
member with the smallest total distance to its cluster, repeat until the
|
|
425
|
+
medoids stop changing.
|
|
426
|
+
|
|
427
|
+
Parameters
|
|
428
|
+
----------
|
|
429
|
+
dist : (n, n) array
|
|
430
|
+
Symmetric distances.
|
|
431
|
+
k : int
|
|
432
|
+
Number of clusters (all objects are medoids when ``k >= n``).
|
|
433
|
+
init : {"build", "park_jun", "random"}
|
|
434
|
+
``"build"``: the PAM BUILD step (most central object, then greedy
|
|
435
|
+
cost reduction; deterministic and robust to duplicate images);
|
|
436
|
+
``"park_jun"``: the *k* objects with the smallest
|
|
437
|
+
``v_j = sum_i d_ij / sum_l d_il`` (Park & Jun's own start, which
|
|
438
|
+
favours central objects); ``"random"``: *k* distinct objects drawn
|
|
439
|
+
with *seed*, best of *n_init* runs.
|
|
440
|
+
seed : int, optional
|
|
441
|
+
Random generator seed (``init="random"``; ties are always broken by
|
|
442
|
+
the lowest index).
|
|
443
|
+
n_init : int
|
|
444
|
+
Random restarts (``init="random"``).
|
|
445
|
+
max_iter : int
|
|
446
|
+
Iteration cap.
|
|
447
|
+
|
|
448
|
+
Returns
|
|
449
|
+
-------
|
|
450
|
+
(medoids, labels)
|
|
451
|
+
Sorted medoid indices and each object's cluster (index into
|
|
452
|
+
*medoids*).
|
|
453
|
+
"""
|
|
454
|
+
d = np.asarray(dist, float)
|
|
455
|
+
n = len(d)
|
|
456
|
+
if n == 0 or k <= 0:
|
|
457
|
+
return np.empty(0, int), np.full(n, -1)
|
|
458
|
+
if k >= n:
|
|
459
|
+
return np.arange(n), np.arange(n)
|
|
460
|
+
if init == "build":
|
|
461
|
+
starts = [_build(d, k)]
|
|
462
|
+
elif init == "park_jun":
|
|
463
|
+
row = d.sum(axis=1)
|
|
464
|
+
v = (d / np.where(row > 0, row, 1.0)[:, None]).sum(axis=0)
|
|
465
|
+
starts = [np.sort(np.argsort(v, kind="stable")[:k])]
|
|
466
|
+
elif init == "random":
|
|
467
|
+
rng = np.random.default_rng(seed)
|
|
468
|
+
starts = [np.sort(rng.choice(n, k, replace=False)) for _ in range(max(1, n_init))]
|
|
469
|
+
else:
|
|
470
|
+
raise ValueError(f"init must be 'build', 'park_jun' or 'random', not {init!r}")
|
|
471
|
+
best: tuple[float, np.ndarray, np.ndarray] | None = None
|
|
472
|
+
for med in starts:
|
|
473
|
+
for _ in range(max_iter):
|
|
474
|
+
labels = np.argmin(d[:, med], axis=1)
|
|
475
|
+
labels[med] = np.arange(k) # duplicates of a medoid stay in their own cluster
|
|
476
|
+
new = med.copy()
|
|
477
|
+
for j in range(k):
|
|
478
|
+
members = np.flatnonzero(labels == j)
|
|
479
|
+
new[j] = members[np.argmin(d[np.ix_(members, members)].sum(axis=1))]
|
|
480
|
+
new = np.sort(new)
|
|
481
|
+
if np.array_equal(new, med):
|
|
482
|
+
break
|
|
483
|
+
med = new
|
|
484
|
+
labels = np.argmin(d[:, med], axis=1)
|
|
485
|
+
labels[med] = np.arange(k)
|
|
486
|
+
cost = float(d[np.arange(n), med[labels]].sum())
|
|
487
|
+
if best is None or cost < best[0] - 1e-12:
|
|
488
|
+
best = (cost, med, labels)
|
|
489
|
+
assert best is not None
|
|
490
|
+
return best[1], best[2]
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@dataclass
|
|
494
|
+
class KMedoidsSelection:
|
|
495
|
+
"""Result of :func:`select_kmedoids` (indices refer to the input rows).
|
|
496
|
+
|
|
497
|
+
Attributes
|
|
498
|
+
----------
|
|
499
|
+
chosen : list of int
|
|
500
|
+
Selected images in pick order: medoids first, then the coverage and
|
|
501
|
+
illumination refinements.
|
|
502
|
+
stage : list of str
|
|
503
|
+
Per entry of *chosen*: ``"medoid"``, ``"coverage"`` or ``"illumination"``.
|
|
504
|
+
medoids, labels : array
|
|
505
|
+
The k-medoids clustering (``labels[i]`` indexes *medoids*; -1 for
|
|
506
|
+
images without an azimuth).
|
|
507
|
+
directions : (n_cells,) int array
|
|
508
|
+
Distinct illumination directions reached per cell (capped at *views*).
|
|
509
|
+
uncovered, poorly_lit : float
|
|
510
|
+
Fractions of the potentially lit cells with no selected image, and
|
|
511
|
+
with fewer than *views* directions.
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
chosen: list[int]
|
|
515
|
+
stage: list[str]
|
|
516
|
+
medoids: np.ndarray
|
|
517
|
+
labels: np.ndarray
|
|
518
|
+
directions: np.ndarray
|
|
519
|
+
uncovered: float
|
|
520
|
+
poorly_lit: float
|
|
521
|
+
history: list[dict[str, Any]] = field(default_factory=list)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def select_kmedoids(
|
|
525
|
+
lit: np.ndarray,
|
|
526
|
+
azimuth: np.ndarray,
|
|
527
|
+
n_clusters: int = 30,
|
|
528
|
+
*,
|
|
529
|
+
coverage: np.ndarray | None = None,
|
|
530
|
+
views: int = 3,
|
|
531
|
+
azimuth_sep: float = 30.0,
|
|
532
|
+
max_uncovered: float = 0.005,
|
|
533
|
+
max_poorly_lit: float = 0.01,
|
|
534
|
+
max_images: int | None = None,
|
|
535
|
+
weights: tuple[float, float] = (1.0, 1.0),
|
|
536
|
+
init: str = "build",
|
|
537
|
+
seed: int | None = 0,
|
|
538
|
+
n_init: int = 1,
|
|
539
|
+
) -> KMedoidsSelection:
|
|
540
|
+
"""k-medoids image selection with coverage and illumination refinement (Bertone et al. 2026, sec. 2).
|
|
541
|
+
|
|
542
|
+
1. Cluster the images with :func:`kmedoids` on
|
|
543
|
+
:func:`selection_distances` (Sun azimuth and coverage differences)
|
|
544
|
+
and take the *n_clusters* medoids.
|
|
545
|
+
2. Add the image lighting the most still-uncovered cells until fewer
|
|
546
|
+
than *max_uncovered* of the potentially lit cells (lit in any image)
|
|
547
|
+
are uncovered.
|
|
548
|
+
3. Add the image giving the most cells a new direction (Sun azimuth at
|
|
549
|
+
least *azimuth_sep* from the cell's directions so far, up to *views*)
|
|
550
|
+
until fewer than *max_poorly_lit* of the potentially lit cells have
|
|
551
|
+
fewer than *views* directions.
|
|
552
|
+
|
|
553
|
+
Steps 2-3 stop early when no image helps or *max_images* is reached;
|
|
554
|
+
ties go to the lowest index, so the result is deterministic (for
|
|
555
|
+
``init="random"`` given *seed*).
|
|
556
|
+
|
|
557
|
+
Parameters
|
|
558
|
+
----------
|
|
559
|
+
lit : (n_images, n_cells) bool array
|
|
560
|
+
Lit (valid and above the shadow threshold) cells per image on a
|
|
561
|
+
common grid, e.g. ``SfsPipeline._lit_masks()``.
|
|
562
|
+
azimuth : (n_images,) array
|
|
563
|
+
Sun azimuths in degrees; images with NaN are never selected.
|
|
564
|
+
n_clusters : int
|
|
565
|
+
Medoids (the paper's ~30 user-defined images).
|
|
566
|
+
coverage : (n_images, n_cells) bool array, optional
|
|
567
|
+
Footprint masks for the distance (default *lit*).
|
|
568
|
+
views, azimuth_sep : int, float
|
|
569
|
+
Directions wanted per cell and their minimum separation (3, 30 deg).
|
|
570
|
+
max_uncovered, max_poorly_lit : float
|
|
571
|
+
Stopping fractions of steps 2 and 3 (0.5 % and 1 %).
|
|
572
|
+
max_images : int, optional
|
|
573
|
+
Cap on the total (medoids are truncated to it first).
|
|
574
|
+
weights, init, seed, n_init
|
|
575
|
+
See :func:`selection_distances` and :func:`kmedoids`.
|
|
576
|
+
"""
|
|
577
|
+
lit = np.asarray(lit, bool)
|
|
578
|
+
az = np.asarray(azimuth, float)
|
|
579
|
+
n, cells = lit.shape
|
|
580
|
+
cov = lit if coverage is None else np.asarray(coverage, bool)
|
|
581
|
+
valid = np.flatnonzero(np.isfinite(az))
|
|
582
|
+
labels = np.full(n, -1)
|
|
583
|
+
if len(valid):
|
|
584
|
+
med, lab = kmedoids(
|
|
585
|
+
selection_distances(cov[valid], az[valid], weights), n_clusters, init, seed, n_init
|
|
586
|
+
)
|
|
587
|
+
medoids = valid[med]
|
|
588
|
+
labels[valid] = lab
|
|
589
|
+
else:
|
|
590
|
+
medoids = np.empty(0, int)
|
|
591
|
+
cap = n if max_images is None else max(0, max_images)
|
|
592
|
+
chosen = [int(i) for i in medoids[:cap]]
|
|
593
|
+
stage = ["medoid"] * len(chosen)
|
|
594
|
+
|
|
595
|
+
potential = lit[valid].any(axis=0) if len(valid) else np.zeros(cells, bool)
|
|
596
|
+
n_pot = max(int(potential.sum()), 1)
|
|
597
|
+
dirs = np.full((cells, views), np.nan)
|
|
598
|
+
count = np.zeros(cells, int)
|
|
599
|
+
|
|
600
|
+
def add(k: int) -> None:
|
|
601
|
+
idx = np.flatnonzero(lit[k] & (count < views))
|
|
602
|
+
if len(idx):
|
|
603
|
+
d = azimuth_difference(dirs[idx], az[k])
|
|
604
|
+
idx = idx[np.all(np.isnan(d) | (d >= azimuth_sep), axis=1)]
|
|
605
|
+
dirs[idx, count[idx]] = az[k]
|
|
606
|
+
count[idx] += 1
|
|
607
|
+
|
|
608
|
+
for k in chosen:
|
|
609
|
+
add(k)
|
|
610
|
+
covered = lit[chosen].any(axis=0) if chosen else np.zeros(cells, bool)
|
|
611
|
+
available = np.zeros(n, bool)
|
|
612
|
+
available[valid] = True
|
|
613
|
+
available[chosen] = False
|
|
614
|
+
history: list[dict[str, Any]] = []
|
|
615
|
+
|
|
616
|
+
def fractions() -> tuple[float, float]:
|
|
617
|
+
return (
|
|
618
|
+
float(np.sum(potential & ~covered) / n_pot),
|
|
619
|
+
float(np.sum(potential & (count < views)) / n_pot),
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
while len(chosen) < cap and fractions()[0] >= max_uncovered and available.any():
|
|
623
|
+
cand = np.flatnonzero(available)
|
|
624
|
+
gain = (lit[cand] & ~covered & potential).sum(axis=1)
|
|
625
|
+
if gain.max() <= 0:
|
|
626
|
+
break
|
|
627
|
+
k = int(cand[np.argmax(gain)])
|
|
628
|
+
chosen.append(k)
|
|
629
|
+
stage.append("coverage")
|
|
630
|
+
covered |= lit[k]
|
|
631
|
+
available[k] = False
|
|
632
|
+
add(k)
|
|
633
|
+
history.append({"image": k, "stage": "coverage", "gain": int(gain.max())})
|
|
634
|
+
|
|
635
|
+
while len(chosen) < cap and fractions()[1] >= max_poorly_lit and available.any():
|
|
636
|
+
open_cells = potential & (count < views)
|
|
637
|
+
best, best_gain = -1, 0
|
|
638
|
+
for j in np.flatnonzero(available):
|
|
639
|
+
idx = np.flatnonzero(lit[j] & open_cells)
|
|
640
|
+
if len(idx) <= best_gain:
|
|
641
|
+
continue
|
|
642
|
+
d = azimuth_difference(dirs[idx], az[j])
|
|
643
|
+
g = int(np.all(np.isnan(d) | (d >= azimuth_sep), axis=1).sum())
|
|
644
|
+
if g > best_gain:
|
|
645
|
+
best, best_gain = int(j), g
|
|
646
|
+
if best < 0:
|
|
647
|
+
break
|
|
648
|
+
chosen.append(best)
|
|
649
|
+
stage.append("illumination")
|
|
650
|
+
covered |= lit[best]
|
|
651
|
+
available[best] = False
|
|
652
|
+
add(best)
|
|
653
|
+
history.append({"image": best, "stage": "illumination", "gain": best_gain})
|
|
654
|
+
|
|
655
|
+
unc, poor = fractions()
|
|
656
|
+
return KMedoidsSelection(chosen, stage, medoids, labels, count, unc, poor, history)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def coverage_matrix(
|
|
660
|
+
obs_df: pd.DataFrame, aoi: AoiLike, grid_km: float | None = None
|
|
661
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
662
|
+
"""Footprint coverage of the AOI grid cells: ``(cover[n_images, n_cells], x, y)``.
|
|
663
|
+
|
|
664
|
+
The grid is the one :func:`select_for_coverage` uses (cell centres in
|
|
665
|
+
the AOI's tangent plane, km).
|
|
666
|
+
"""
|
|
667
|
+
geoms, target = _aoi_cover(obs_df, aoi)
|
|
668
|
+
cx, cy = _grid(target, grid_km)
|
|
669
|
+
cover = (
|
|
670
|
+
np.stack(
|
|
671
|
+
[shapely.intersects_xy(g, cx, cy) if g is not None else np.zeros(len(cx), bool) for g in geoms]
|
|
672
|
+
)
|
|
673
|
+
if geoms
|
|
674
|
+
else np.zeros((0, len(cx)), bool)
|
|
675
|
+
)
|
|
676
|
+
return cover, cx, cy
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def select_kmedoids_footprints(
|
|
680
|
+
obs_df: pd.DataFrame,
|
|
681
|
+
aoi: AoiLike,
|
|
682
|
+
n_clusters: int = 30,
|
|
683
|
+
incidence: tuple[float, float] | None = None,
|
|
684
|
+
max_emission: float | None = None,
|
|
685
|
+
grid_km: float | None = None,
|
|
686
|
+
**options: Any,
|
|
687
|
+
) -> pd.DataFrame:
|
|
688
|
+
""":func:`select_kmedoids` on catalogue footprints over an AOI (footprint = lit, no shadows).
|
|
689
|
+
|
|
690
|
+
*options* go to :func:`select_kmedoids`. Returns the selected rows
|
|
691
|
+
sorted by ``sub_solar_azimuth`` with ``selection_order``,
|
|
692
|
+
``selection_stage`` and ``cluster`` columns; ``attrs`` holds
|
|
693
|
+
``uncovered``, ``poorly_lit`` and ``n_cells``.
|
|
694
|
+
"""
|
|
695
|
+
import pandas as pd
|
|
696
|
+
|
|
697
|
+
df = _candidates(obs_df, incidence, max_emission, aoi, 0.0)
|
|
698
|
+
if df.empty:
|
|
699
|
+
out = df.assign(
|
|
700
|
+
selection_order=pd.Series(dtype=int),
|
|
701
|
+
selection_stage=pd.Series(dtype=str),
|
|
702
|
+
cluster=pd.Series(dtype=int),
|
|
703
|
+
)
|
|
704
|
+
out.attrs.update(uncovered=1.0, poorly_lit=1.0, n_cells=0)
|
|
705
|
+
return out
|
|
706
|
+
cover, cx, _ = coverage_matrix(df, aoi, grid_km)
|
|
707
|
+
res = select_kmedoids(cover, df["sub_solar_azimuth"].to_numpy(float), n_clusters, **options)
|
|
708
|
+
picked = df.iloc[res.chosen].copy()
|
|
709
|
+
picked["selection_order"] = np.arange(len(res.chosen))
|
|
710
|
+
picked["selection_stage"] = res.stage
|
|
711
|
+
picked["cluster"] = res.labels[res.chosen]
|
|
712
|
+
out = sort_by_sun_azimuth(picked)
|
|
713
|
+
out.attrs.update(uncovered=res.uncovered, poorly_lit=res.poorly_lit, n_cells=int(len(cx)))
|
|
714
|
+
return out
|