lunar-toolkit 0.1.0__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 +451 -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 +229 -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 +719 -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 +1559 -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/basemaps/lola_dem_global.tif +0 -0
- lunar_toolkit/static/basemaps/lola_dem_north.tif +0 -0
- lunar_toolkit/static/basemaps/lola_dem_south.tif +0 -0
- lunar_toolkit/static/basemaps/lola_shade_global.jpg +0 -0
- lunar_toolkit/static/basemaps/lola_shade_north.jpg +0 -0
- lunar_toolkit/static/basemaps/lola_shade_south.jpg +0 -0
- lunar_toolkit/static/basemaps/manifest.json +11 -0
- lunar_toolkit/static/basemaps/wac_global.jpg +0 -0
- lunar_toolkit/static/basemaps/wac_north.jpg +0 -0
- lunar_toolkit/static/basemaps/wac_south.jpg +0 -0
- lunar_toolkit/static/index.html +4197 -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.dist-info/METADATA +180 -0
- lunar_toolkit-0.1.0.dist-info/RECORD +65 -0
- lunar_toolkit-0.1.0.dist-info/WHEEL +5 -0
- lunar_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- lunar_toolkit-0.1.0.dist-info/licenses/LICENSE +28 -0
- lunar_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Find, download and process lunar orbital imagery and build DEMs with ISIS + ASP."""
|
|
2
|
+
|
|
3
|
+
from .downloader import PdsDownloader
|
|
4
|
+
from .lroc import CumIndex, NacImage, NacImageCollection, NacProcessor
|
|
5
|
+
|
|
6
|
+
__all__ = ["CumIndex", "NacImage", "NacImageCollection", "NacProcessor", "PdsDownloader"]
|
lunar_toolkit/asp.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Ames Stereo Pipeline wrappers: positional inputs as arguments, other options as keywords."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .tools import ToolError, asp_options, gdal_options, is_dry_run, run
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _paths(items: Sequence[Any]) -> list[str]:
|
|
19
|
+
return [str(p) for p in items]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parent(prefix: Path) -> None:
|
|
23
|
+
if not is_dry_run():
|
|
24
|
+
Path(prefix).parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def mapproject(
|
|
28
|
+
dem: Path, image: Path, out: Path, camera: Path | None = None, *, cwd: Path | None = None, **options: Any
|
|
29
|
+
) -> Path:
|
|
30
|
+
"""Orthorectify *image* onto *dem* (``tr=``, ``t_projwin=``, ``bundle_adjust_prefix=``)."""
|
|
31
|
+
_parent(out)
|
|
32
|
+
cams = [camera] if camera else []
|
|
33
|
+
run("mapproject", [*asp_options(options), dem, image, *cams, out], cwd=cwd)
|
|
34
|
+
return Path(out)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bundle_adjust(
|
|
38
|
+
images: Sequence[Path],
|
|
39
|
+
out_prefix: Path,
|
|
40
|
+
cameras: Sequence[Path] = (),
|
|
41
|
+
*,
|
|
42
|
+
mapprojected: Sequence[Path] | None = None,
|
|
43
|
+
dem: Path | None = None,
|
|
44
|
+
parallel: bool = False,
|
|
45
|
+
cwd: Path | None = None,
|
|
46
|
+
**options: Any,
|
|
47
|
+
) -> Path:
|
|
48
|
+
"""Run ``bundle_adjust`` (or ``parallel_bundle_adjust`` with *parallel*).
|
|
49
|
+
|
|
50
|
+
*mapprojected* (same order as *images*) and the *dem* they were projected
|
|
51
|
+
onto become ``--mapprojected-data``, which finds matches between images of
|
|
52
|
+
very different illumination. Returns *out_prefix*.
|
|
53
|
+
|
|
54
|
+
Raises :class:`~lunar_toolkit.tools.ToolError` when the run writes no fresh
|
|
55
|
+
``.adjust`` file for some image: ASP exits 0 even when it finds nothing to
|
|
56
|
+
optimize ("No points to optimize ... Cannot continue").
|
|
57
|
+
"""
|
|
58
|
+
_parent(out_prefix)
|
|
59
|
+
if mapprojected is not None:
|
|
60
|
+
options["mapprojected_data"] = " ".join(_paths([*mapprojected, *([dem] if dem else [])]))
|
|
61
|
+
tool = "parallel_bundle_adjust" if parallel else "bundle_adjust"
|
|
62
|
+
start = time.time()
|
|
63
|
+
run(tool, [*_paths(images), *_paths(cameras), *asp_options(options), "-o", out_prefix], cwd=cwd)
|
|
64
|
+
if not is_dry_run():
|
|
65
|
+
prefix = Path(cwd or ".") / out_prefix
|
|
66
|
+
cams: Sequence[Path | None] = cameras if len(cameras) == len(images) else [None] * len(images)
|
|
67
|
+
missing = [
|
|
68
|
+
p
|
|
69
|
+
for p in (bundle_adjust_file_name(prefix, im, cam) for im, cam in zip(images, cams))
|
|
70
|
+
if not p.is_file() or p.stat().st_mtime < start - 2
|
|
71
|
+
]
|
|
72
|
+
if missing:
|
|
73
|
+
raise ToolError(
|
|
74
|
+
f"{tool} wrote no adjustment for {len(missing)} of {len(images)} images "
|
|
75
|
+
f"(e.g. {missing[0].name}); see {out_prefix}-log-bundle_adjust-*.txt"
|
|
76
|
+
)
|
|
77
|
+
return Path(out_prefix)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def apply_transform(
|
|
81
|
+
images: Sequence[Path],
|
|
82
|
+
transform: Path,
|
|
83
|
+
out_prefix: Path,
|
|
84
|
+
cameras: Sequence[Path] = (),
|
|
85
|
+
*,
|
|
86
|
+
input_adjustments_prefix: Path | None = None,
|
|
87
|
+
cwd: Path | None = None,
|
|
88
|
+
**options: Any,
|
|
89
|
+
) -> Path:
|
|
90
|
+
"""Apply a ``pc_align`` 4×4 transform to (adjusted) cameras, without optimizing."""
|
|
91
|
+
return bundle_adjust(
|
|
92
|
+
images,
|
|
93
|
+
out_prefix,
|
|
94
|
+
cameras,
|
|
95
|
+
initial_transform=transform,
|
|
96
|
+
input_adjustments_prefix=input_adjustments_prefix,
|
|
97
|
+
apply_initial_transform_only=True,
|
|
98
|
+
cwd=cwd,
|
|
99
|
+
**options,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def sfs(
|
|
104
|
+
dem: Path,
|
|
105
|
+
images: Sequence[Path],
|
|
106
|
+
out_prefix: Path,
|
|
107
|
+
cameras: Sequence[Path] = (),
|
|
108
|
+
*,
|
|
109
|
+
parallel: bool = False,
|
|
110
|
+
cwd: Path | None = None,
|
|
111
|
+
**options: Any,
|
|
112
|
+
) -> Path:
|
|
113
|
+
"""Run ``sfs`` (or ``parallel_sfs``); returns ``<out_prefix>-DEM-final.tif``."""
|
|
114
|
+
_parent(out_prefix)
|
|
115
|
+
tool = "parallel_sfs" if parallel else "sfs"
|
|
116
|
+
run(
|
|
117
|
+
tool, ["-i", dem, *_paths(images), *_paths(cameras), *asp_options(options), "-o", out_prefix], cwd=cwd
|
|
118
|
+
)
|
|
119
|
+
return Path(f"{out_prefix}-DEM-final.tif")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class SunAngles:
|
|
124
|
+
image: str
|
|
125
|
+
azimuth: float
|
|
126
|
+
elevation: float
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_SUN_RE = re.compile(r"Sun azimuth and elevation for: (?P<image>\S+) are (?P<az>\S+) and (?P<el>\S+) degrees")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_sun_angles(text: str) -> list[SunAngles]:
|
|
133
|
+
"""Parse ``sfs --query`` output into per-image Sun azimuth / elevation."""
|
|
134
|
+
return [SunAngles(m["image"], float(m["az"]), float(m["el"])) for m in _SUN_RE.finditer(text)]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def sun_angles(
|
|
138
|
+
dem: Path,
|
|
139
|
+
images: Sequence[Path],
|
|
140
|
+
cameras: Sequence[Path] = (),
|
|
141
|
+
*,
|
|
142
|
+
out_prefix: Path = Path("sfs_query/run"),
|
|
143
|
+
cwd: Path | None = None,
|
|
144
|
+
**options: Any,
|
|
145
|
+
) -> list[SunAngles]:
|
|
146
|
+
"""Sun azimuth (from north) and elevation per image at the DEM centre.
|
|
147
|
+
|
|
148
|
+
Runs ``sfs --query``; results come back in input order. Sort by azimuth
|
|
149
|
+
before bundle adjustment and SfS.
|
|
150
|
+
"""
|
|
151
|
+
_parent(out_prefix)
|
|
152
|
+
res = run(
|
|
153
|
+
"sfs",
|
|
154
|
+
["-i", dem, *_paths(images), *_paths(cameras), "--query", *asp_options(options), "-o", out_prefix],
|
|
155
|
+
cwd=cwd,
|
|
156
|
+
capture=True,
|
|
157
|
+
)
|
|
158
|
+
return parse_sun_angles(res.stdout or "")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def pc_align(
|
|
162
|
+
reference: Path,
|
|
163
|
+
source: Path,
|
|
164
|
+
out_prefix: Path,
|
|
165
|
+
*,
|
|
166
|
+
max_displacement: float,
|
|
167
|
+
cwd: Path | None = None,
|
|
168
|
+
**options: Any,
|
|
169
|
+
) -> Path:
|
|
170
|
+
"""Align *source* to *reference*; returns ``<out_prefix>-transform.txt``."""
|
|
171
|
+
_parent(out_prefix)
|
|
172
|
+
run(
|
|
173
|
+
"pc_align",
|
|
174
|
+
["--max-displacement", max_displacement, *asp_options(options), reference, source, "-o", out_prefix],
|
|
175
|
+
cwd=cwd,
|
|
176
|
+
)
|
|
177
|
+
return Path(f"{out_prefix}-transform.txt")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def point2dem(cloud: Path, *, cwd: Path | None = None, **options: Any) -> Path:
|
|
181
|
+
"""Grid a point cloud; returns ``<prefix>-DEM.tif`` (prefix from ``o=`` or the cloud)."""
|
|
182
|
+
run("point2dem", [*asp_options(options), cloud], cwd=cwd)
|
|
183
|
+
prefix = options.get("o") or options.get("output_prefix")
|
|
184
|
+
if prefix is None:
|
|
185
|
+
stem = str(cloud)
|
|
186
|
+
prefix = stem[: -len("-PC.tif")] if stem.endswith("-PC.tif") else str(Path(stem).with_suffix(""))
|
|
187
|
+
return Path(f"{prefix}-DEM.tif")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def stereo(
|
|
191
|
+
left: Path,
|
|
192
|
+
right: Path,
|
|
193
|
+
out_prefix: Path,
|
|
194
|
+
left_camera: Path | None = None,
|
|
195
|
+
right_camera: Path | None = None,
|
|
196
|
+
*,
|
|
197
|
+
dem: Path | None = None,
|
|
198
|
+
parallel: bool = True,
|
|
199
|
+
cwd: Path | None = None,
|
|
200
|
+
**options: Any,
|
|
201
|
+
) -> Path:
|
|
202
|
+
"""Run ``parallel_stereo`` (or ``stereo``); returns ``<out_prefix>-PC.tif``.
|
|
203
|
+
|
|
204
|
+
Pass *dem* when the inputs are mapprojected onto it.
|
|
205
|
+
"""
|
|
206
|
+
_parent(out_prefix)
|
|
207
|
+
cams = [c for c in (left_camera, right_camera) if c]
|
|
208
|
+
tail = [dem] if dem else []
|
|
209
|
+
tool = "parallel_stereo" if parallel else "stereo"
|
|
210
|
+
run(tool, [*asp_options(options), left, right, *cams, out_prefix, *tail], cwd=cwd)
|
|
211
|
+
return Path(f"{out_prefix}-PC.tif")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def dem_mosaic(inputs: Sequence[Path], out: Path, *, cwd: Path | None = None, **options: Any) -> Path:
|
|
215
|
+
"""Mosaic DEMs or images (``max=True`` for a maximally-lit mosaic)."""
|
|
216
|
+
_parent(out)
|
|
217
|
+
run("dem_mosaic", [*_paths(inputs), *asp_options(options), "-o", out], cwd=cwd)
|
|
218
|
+
return Path(out)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def hillshade(
|
|
222
|
+
dem: Path,
|
|
223
|
+
out: Path | None = None,
|
|
224
|
+
*,
|
|
225
|
+
azimuth: float = 300,
|
|
226
|
+
elevation: float = 20,
|
|
227
|
+
cwd: Path | None = None,
|
|
228
|
+
**options: Any,
|
|
229
|
+
) -> Path:
|
|
230
|
+
out = Path(out) if out else Path(dem).with_name(Path(dem).stem + "_hillshade.tif")
|
|
231
|
+
run(
|
|
232
|
+
"hillshade",
|
|
233
|
+
[dem, "--azimuth", azimuth, "--elevation", elevation, *asp_options(options), "-o", out],
|
|
234
|
+
cwd=cwd,
|
|
235
|
+
)
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def geodiff(dem1: Path, dem2: Path, out_prefix: Path, *, cwd: Path | None = None, **options: Any) -> Path:
|
|
240
|
+
"""``dem1 - dem2`` (``absolute=True`` for |diff|); returns ``<out_prefix>-diff.tif``."""
|
|
241
|
+
_parent(out_prefix)
|
|
242
|
+
run("geodiff", [*asp_options(options), dem1, dem2, "-o", out_prefix], cwd=cwd)
|
|
243
|
+
return Path(f"{out_prefix}-diff.tif")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def gdal_translate(
|
|
247
|
+
src: Path,
|
|
248
|
+
dst: Path,
|
|
249
|
+
*,
|
|
250
|
+
projwin: Sequence[float] | None = None,
|
|
251
|
+
tr: float | None = None,
|
|
252
|
+
r: str | None = None,
|
|
253
|
+
cwd: Path | None = None,
|
|
254
|
+
**options: Any,
|
|
255
|
+
) -> Path:
|
|
256
|
+
"""Crop (``projwin`` = ulx uly lrx lry) and / or resample (``tr`` metres, ``r`` method)."""
|
|
257
|
+
_parent(dst)
|
|
258
|
+
opts = {
|
|
259
|
+
"projwin": projwin,
|
|
260
|
+
"tr": (tr, tr) if tr is not None else None,
|
|
261
|
+
"r": r,
|
|
262
|
+
"of": "GTiff",
|
|
263
|
+
"co": "COMPRESS=LZW",
|
|
264
|
+
**options,
|
|
265
|
+
}
|
|
266
|
+
run("gdal_translate", [*gdal_options(opts), src, dst], cwd=cwd)
|
|
267
|
+
return Path(dst)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def bundle_adjust_file_name(prefix: Path, image: Path, camera: Path | None = None) -> Path:
|
|
271
|
+
"""``<prefix>-<stem>.adjust`` as ASP names it (stem of *camera*, else *image*, up to the last dot)."""
|
|
272
|
+
name = Path(camera or image).name
|
|
273
|
+
stem = name[: name.rfind(".")] if "." in name else name
|
|
274
|
+
return Path(f"{prefix}-{stem}.adjust")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True)
|
|
278
|
+
class MatchOffsetStats:
|
|
279
|
+
"""One row of ``<prefix>-mapproj_match_offset_stats.txt`` (metres; -1 when no matches)."""
|
|
280
|
+
|
|
281
|
+
image: str
|
|
282
|
+
p25: float
|
|
283
|
+
p50: float
|
|
284
|
+
p75: float
|
|
285
|
+
p85: float
|
|
286
|
+
p95: float
|
|
287
|
+
count: int
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def read_mapproj_offset_stats(path: Path) -> dict[str, MatchOffsetStats]:
|
|
291
|
+
"""Parse ``bundle_adjust --mapproj-dem`` per-image offsets, keyed by image file name.
|
|
292
|
+
|
|
293
|
+
Format (ASP ``saveMapprojOffsets``): ``# image_name 25% 50% 75% 85% 95% count``.
|
|
294
|
+
"""
|
|
295
|
+
out = {}
|
|
296
|
+
for line in Path(path).read_text().splitlines():
|
|
297
|
+
parts = line.split()
|
|
298
|
+
if not parts or parts[0].startswith("#") or len(parts) < 7:
|
|
299
|
+
continue
|
|
300
|
+
p25, p50, p75, p85, p95 = map(float, parts[1:6])
|
|
301
|
+
out[Path(parts[0]).name] = MatchOffsetStats(parts[0], p25, p50, p75, p85, p95, int(float(parts[6])))
|
|
302
|
+
return out
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass(frozen=True)
|
|
306
|
+
class ResidualStats:
|
|
307
|
+
"""One row of ``<prefix>-final_residuals_stats.txt`` (pixels)."""
|
|
308
|
+
|
|
309
|
+
image: str
|
|
310
|
+
mean: float
|
|
311
|
+
median: float
|
|
312
|
+
count: int
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def read_residual_stats(path: Path) -> dict[str, ResidualStats]:
|
|
316
|
+
"""Parse per-camera reprojection errors (``# Image, mean, median, count``), keyed by file name."""
|
|
317
|
+
out = {}
|
|
318
|
+
for line in Path(path).read_text().splitlines():
|
|
319
|
+
parts = [p.strip() for p in line.split(",")]
|
|
320
|
+
if not parts[0] or parts[0].startswith("#") or len(parts) < 4:
|
|
321
|
+
continue
|
|
322
|
+
out[Path(parts[0]).name] = ResidualStats(parts[0], float(parts[1]), float(parts[2]), int(parts[3]))
|
|
323
|
+
return out
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def read_residual_norms(path: Path) -> dict[str, np.ndarray]:
|
|
327
|
+
"""Per-image reprojection error norms (pixels) from ``<prefix>-final_residuals_raw_pixels.txt``.
|
|
328
|
+
|
|
329
|
+
The file has, per image, a ``name, count`` line followed by *count* ``ex, ey`` lines.
|
|
330
|
+
"""
|
|
331
|
+
out: dict[str, np.ndarray] = {}
|
|
332
|
+
lines = Path(path).read_text().splitlines()
|
|
333
|
+
i = 0
|
|
334
|
+
while i < len(lines):
|
|
335
|
+
head = [p.strip() for p in lines[i].split(",")]
|
|
336
|
+
i += 1
|
|
337
|
+
if len(head) != 2 or not head[0] or head[0].startswith("#"):
|
|
338
|
+
continue
|
|
339
|
+
n = int(head[1])
|
|
340
|
+
xy = np.array([[float(v) for v in ln.split(",")] for ln in lines[i : i + n]]).reshape(-1, 2)
|
|
341
|
+
out[Path(head[0]).name] = np.hypot(xy[:, 0], xy[:, 1])
|
|
342
|
+
i += n
|
|
343
|
+
return out
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def image_subset(
|
|
347
|
+
images: Sequence[Path], out: Path, *, threshold: float, cwd: Path | None = None, **options: Any
|
|
348
|
+
) -> list[tuple[str, int]]:
|
|
349
|
+
"""Run ``image_subset``; returns ``(image, contributing pixels)`` best first.
|
|
350
|
+
|
|
351
|
+
Writes the image list to ``<out>.in.txt``; ``t_projwin=(xmin, ymin, xmax, ymax)`` limits
|
|
352
|
+
the evaluated extent. Empty in a dry run.
|
|
353
|
+
"""
|
|
354
|
+
_parent(out)
|
|
355
|
+
image_list = Path(f"{out}.in.txt")
|
|
356
|
+
if not is_dry_run():
|
|
357
|
+
image_list.write_text("".join(f"{p}\n" for p in images))
|
|
358
|
+
run(
|
|
359
|
+
"image_subset",
|
|
360
|
+
["--image-list", image_list, "--threshold", threshold, *asp_options(options), "-o", out],
|
|
361
|
+
cwd=cwd,
|
|
362
|
+
)
|
|
363
|
+
if is_dry_run() or not Path(out).exists():
|
|
364
|
+
return []
|
|
365
|
+
return [
|
|
366
|
+
(parts[0], int(float(parts[1])))
|
|
367
|
+
for parts in (ln.split() for ln in Path(out).read_text().splitlines())
|
|
368
|
+
if len(parts) >= 2 and not parts[0].startswith("#")
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def raster_info(path: Path) -> dict:
|
|
373
|
+
"""``gdalinfo -json`` of *path* (empty in a dry run)."""
|
|
374
|
+
res = run("gdalinfo", ["-json", path], capture=True)
|
|
375
|
+
return json.loads(res.stdout) if res.stdout else {}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def raster_bounds(path: Path) -> tuple[float, float, float, float] | None:
|
|
379
|
+
"""``(ulx, uly, lrx, lry)`` of a north-up raster, or None in a dry run."""
|
|
380
|
+
info = raster_info(path)
|
|
381
|
+
if not info:
|
|
382
|
+
return None
|
|
383
|
+
x0, dx, _, y0, _, dy = info["geoTransform"]
|
|
384
|
+
nx, ny = info["size"]
|
|
385
|
+
return x0, y0, x0 + nx * dx, y0 + ny * dy
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def srs_proj4(path: Path) -> str:
|
|
389
|
+
"""PROJ string of a raster's spatial reference (``gdalsrsinfo -o proj4``; empty in a dry run)."""
|
|
390
|
+
res = run("gdalsrsinfo", ["-o", "proj4", path], capture=True)
|
|
391
|
+
return (res.stdout or "").strip().strip("'")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@dataclass(frozen=True)
|
|
395
|
+
class Raster:
|
|
396
|
+
"""Band 1 of a north-up raster: ``data[row, col]`` (NaN = nodata), top-left corner and pixel size."""
|
|
397
|
+
|
|
398
|
+
data: np.ndarray
|
|
399
|
+
x0: float
|
|
400
|
+
y0: float
|
|
401
|
+
dx: float
|
|
402
|
+
dy: float
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def read_raster(
|
|
406
|
+
path: Path,
|
|
407
|
+
*,
|
|
408
|
+
projwin: Sequence[float] | None = None,
|
|
409
|
+
outsize: tuple[int, int] | None = None,
|
|
410
|
+
r: str = "average",
|
|
411
|
+
) -> Raster | None:
|
|
412
|
+
"""Read (a resampled window of) a raster through ``gdal_translate -of AAIGrid /vsistdout/``.
|
|
413
|
+
|
|
414
|
+
*projwin* (``ulx uly lrx lry``) may extend past the raster (filled with nodata) and
|
|
415
|
+
*outsize* ``(columns, rows)`` resamples with *r*, so several images read with the
|
|
416
|
+
same window share one grid. Meant for small grids; returns None in a dry run.
|
|
417
|
+
"""
|
|
418
|
+
opts = {
|
|
419
|
+
"q": True,
|
|
420
|
+
"of": "AAIGrid",
|
|
421
|
+
"ot": "Float32",
|
|
422
|
+
"b": 1,
|
|
423
|
+
"projwin": projwin,
|
|
424
|
+
"outsize": outsize,
|
|
425
|
+
"r": r if outsize else None,
|
|
426
|
+
}
|
|
427
|
+
res = run("gdal_translate", [*gdal_options(opts), path, "/vsistdout/"], capture=True)
|
|
428
|
+
return parse_aaigrid(res.stdout) if res.stdout else None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def parse_aaigrid(text: str) -> Raster:
|
|
432
|
+
"""Parse ESRI ASCII grid text (as GDAL's AAIGrid driver writes it)."""
|
|
433
|
+
lines = text.splitlines()
|
|
434
|
+
head: dict[str, float] = {}
|
|
435
|
+
k = 0
|
|
436
|
+
while k < len(lines) and lines[k][:1].isalpha():
|
|
437
|
+
key, value = lines[k].split()[:2]
|
|
438
|
+
head[key.lower()] = float(value)
|
|
439
|
+
k += 1
|
|
440
|
+
nx, ny = int(head["ncols"]), int(head["nrows"])
|
|
441
|
+
dx = head.get("dx", head.get("cellsize"))
|
|
442
|
+
dy = head.get("dy", head.get("cellsize"))
|
|
443
|
+
if dx is None or dy is None:
|
|
444
|
+
raise ValueError("AAIGrid header lacks cellsize / dx / dy")
|
|
445
|
+
x0 = head["xllcorner"] if "xllcorner" in head else head["xllcenter"] - dx / 2
|
|
446
|
+
yll = head["yllcorner"] if "yllcorner" in head else head["yllcenter"] - dy / 2
|
|
447
|
+
# GDAL also writes the .prj (WKT) to /vsistdout/ after the cells.
|
|
448
|
+
data = np.array(" ".join(lines[k:]).split()[: nx * ny], dtype=float).reshape(ny, nx)
|
|
449
|
+
if "nodata_value" in head:
|
|
450
|
+
data[data == head["nodata_value"]] = np.nan
|
|
451
|
+
return Raster(data, x0, yll + ny * dy, dx, -dy)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""``lunar`` CLI; each subcommand module exports COMMAND, add_parser(sub) and run(args)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import (
|
|
8
|
+
app,
|
|
9
|
+
build_db,
|
|
10
|
+
download,
|
|
11
|
+
kaguya,
|
|
12
|
+
ode_search,
|
|
13
|
+
pairs,
|
|
14
|
+
process,
|
|
15
|
+
query,
|
|
16
|
+
reference,
|
|
17
|
+
search,
|
|
18
|
+
serve,
|
|
19
|
+
sfs,
|
|
20
|
+
sfs_report,
|
|
21
|
+
shadowcam,
|
|
22
|
+
stereo,
|
|
23
|
+
workspace,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_COMMANDS = [
|
|
27
|
+
build_db,
|
|
28
|
+
download,
|
|
29
|
+
query,
|
|
30
|
+
search,
|
|
31
|
+
ode_search,
|
|
32
|
+
pairs,
|
|
33
|
+
reference,
|
|
34
|
+
process,
|
|
35
|
+
kaguya,
|
|
36
|
+
shadowcam,
|
|
37
|
+
stereo,
|
|
38
|
+
sfs,
|
|
39
|
+
sfs_report,
|
|
40
|
+
workspace,
|
|
41
|
+
serve,
|
|
42
|
+
app,
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
prog="lunar",
|
|
49
|
+
description=(
|
|
50
|
+
"Lunar imagery toolkit - build LROC index databases, search footprints, "
|
|
51
|
+
"query ODE (ShadowCam, LROC, Kaguya TC and MI), download and import images into ISIS, "
|
|
52
|
+
"build stereo and shape-from-shading DEMs with ISIS + ASP, and fetch Artemis III "
|
|
53
|
+
"reference data."
|
|
54
|
+
),
|
|
55
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"-v",
|
|
59
|
+
"--verbose",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="Enable DEBUG-level logging.",
|
|
62
|
+
)
|
|
63
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
64
|
+
for cmd in _COMMANDS:
|
|
65
|
+
cmd.add_parser(sub)
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None) -> int:
|
|
70
|
+
parser = _build_parser()
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
|
|
73
|
+
logging.basicConfig(
|
|
74
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
75
|
+
format="%(asctime)s %(levelname)-8s %(message)s",
|
|
76
|
+
datefmt="%H:%M:%S",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
for cmd in _COMMANDS:
|
|
80
|
+
if args.cmd == cmd.COMMAND:
|
|
81
|
+
return cmd.run(args)
|
|
82
|
+
|
|
83
|
+
parser.print_help()
|
|
84
|
+
return 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
sys.exit(main())
|