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/cli/sfs.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""sfs - shape-from-shading DEM from LROC NAC images (ISIS + ASP)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..constants import CAMERA_SOURCES, REGISTRATION_MODES, SELECTION_METHODS, SFS_CAMERA_MODELS
|
|
8
|
+
|
|
9
|
+
COMMAND = "sfs"
|
|
10
|
+
|
|
11
|
+
_OPTION_FLAGS = {"ba_opt": "bundle_adjust_options", "refine_opt": "refine_options", "sfs_opt": "sfs_options"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _reduce(value: str):
|
|
15
|
+
low = value.lower()
|
|
16
|
+
if low == "auto":
|
|
17
|
+
return "auto"
|
|
18
|
+
if low == "none":
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
return int(value)
|
|
22
|
+
except ValueError:
|
|
23
|
+
raise argparse.ArgumentTypeError(f"expected an integer, auto or none, got {value!r}") from None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def add_parser(sub: argparse._SubParsersAction) -> None:
|
|
27
|
+
p = sub.add_parser(
|
|
28
|
+
COMMAND,
|
|
29
|
+
help="Build a shape-from-shading DEM from NAC images and a prior DEM (ISIS + ASP).",
|
|
30
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
31
|
+
argument_default=argparse.SUPPRESS,
|
|
32
|
+
epilog="""
|
|
33
|
+
Examples
|
|
34
|
+
--------
|
|
35
|
+
lunar sfs --config examples/malapert_crop.yaml
|
|
36
|
+
lunar sfs --config run.yaml --res 2 --sfs-opt smoothness_weight=0.05 # flags override the file
|
|
37
|
+
lunar sfs --config run.yaml --dump-config > effective.yaml # print every setting
|
|
38
|
+
lunar sfs --config run.yaml --check # compare tools / config / inputs with the work dir
|
|
39
|
+
lunar sfs --region malapert --crop-km 2.5 --work-dir malapert_crop # PGDA prior DEM, ROI search
|
|
40
|
+
lunar sfs --aoi site.geojson --dem LDEM_87S_5MPP_ADJ.TIF --work-dir malapert
|
|
41
|
+
lunar sfs M1234567890LE M1234567891RE ... --dem LDEM_87S_5MPP_ADJ.TIF \\
|
|
42
|
+
--projwin -10000 50000 0 40000 --res 5 --work-dir malapert
|
|
43
|
+
lunar sfs raw/*.IMG --dem ldem.tif --registration residuals --processes 8
|
|
44
|
+
lunar sfs --aoi "POLYGON((...))" --dem ldem.tif --dry-run # print the commands only
|
|
45
|
+
|
|
46
|
+
Config files are flat YAML mappings of the same settings (keys as printed by
|
|
47
|
+
--dump-config); command-line flags override them. Each run writes the effective
|
|
48
|
+
settings to WORK_DIR/config.yaml and appends its environment, tool versions,
|
|
49
|
+
input checksums and per-step commands to WORK_DIR/provenance.json.
|
|
50
|
+
|
|
51
|
+
Steps (Bertone et al. 2026, PSJ 7:118): [search + slew/resolution cut + download] ->
|
|
52
|
+
calibrate + CSM cameras -> crop/resample prior DEM -> sort by Sun azimuth -> mapproject at
|
|
53
|
+
--ba-res -> bundle adjust -> drop badly registered images -> register to the prior DEM
|
|
54
|
+
(stereo DEMs or BA points, translation only) -> refine with --heights-from-dem -> drop
|
|
55
|
+
again -> mapproject at --res -> max-lit QA mosaics (all + 30 deg Sun bins) -> select images ->
|
|
56
|
+
reduce cubes to --res -> parallel_sfs -> products.
|
|
57
|
+
Re-running resumes: steps whose outputs exist are skipped (--force to redo).
|
|
58
|
+
Needs ISIS and ASP on PATH (or ASP_ROOT / ISISROOT).
|
|
59
|
+
""",
|
|
60
|
+
)
|
|
61
|
+
p.add_argument(
|
|
62
|
+
"sources",
|
|
63
|
+
nargs="*",
|
|
64
|
+
default=[],
|
|
65
|
+
metavar="SOURCE",
|
|
66
|
+
help="NAC EDR product IDs (downloaded), .IMG or calibrated .cub paths "
|
|
67
|
+
"(default with --aoi / --region: search the local EDR index).",
|
|
68
|
+
)
|
|
69
|
+
p.add_argument("--config", metavar="YAML", help="Run configuration file (flags override it).")
|
|
70
|
+
p.add_argument(
|
|
71
|
+
"--dump-config", action="store_true", help="Print the effective configuration as YAML and exit."
|
|
72
|
+
)
|
|
73
|
+
p.add_argument(
|
|
74
|
+
"--check",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="Compare tool versions, settings and input files with WORK_DIR/provenance.json and exit "
|
|
77
|
+
"(status 1 when they differ).",
|
|
78
|
+
)
|
|
79
|
+
p.add_argument("--from-file", metavar="FILE", help="Text file of sources, one per line.")
|
|
80
|
+
p.add_argument(
|
|
81
|
+
"--dem", metavar="TIF", help="Prior DEM (e.g. LOLA LDEM GeoTIFF), or pgda (default with --region)."
|
|
82
|
+
)
|
|
83
|
+
p.add_argument(
|
|
84
|
+
"--region",
|
|
85
|
+
metavar="REGION",
|
|
86
|
+
help="Artemis III region (id, name or PGDA site, see lunar reference list): sets the crop, "
|
|
87
|
+
"product prefix and image search, and the PGDA LOLA 5 m prior DEM.",
|
|
88
|
+
)
|
|
89
|
+
p.add_argument(
|
|
90
|
+
"--crop-km", type=float, metavar="KM", help="Square crop of this side at the region centre."
|
|
91
|
+
)
|
|
92
|
+
window = p.add_mutually_exclusive_group()
|
|
93
|
+
window.add_argument(
|
|
94
|
+
"--aoi",
|
|
95
|
+
metavar="FILE_OR_WKT",
|
|
96
|
+
help="Area of interest (GeoJSON / WKT file or WKT, lon/lat): crops a polar stereographic "
|
|
97
|
+
"prior DEM and, without SOURCEs, selects the images.",
|
|
98
|
+
)
|
|
99
|
+
window.add_argument(
|
|
100
|
+
"--projwin",
|
|
101
|
+
nargs=4,
|
|
102
|
+
type=float,
|
|
103
|
+
metavar=("ULX", "ULY", "LRX", "LRY"),
|
|
104
|
+
help="Crop window in the prior DEM's projected units.",
|
|
105
|
+
)
|
|
106
|
+
p.add_argument("--work-dir", metavar="DIR", help="Working / output directory (default: sfs_work).")
|
|
107
|
+
p.add_argument("--res", dest="resolution", type=float, metavar="M", help="SfS DEM m/px (default: 5).")
|
|
108
|
+
p.add_argument("--preset", choices=["bertone", "asp"], help="Option defaults (default: bertone).")
|
|
109
|
+
p.add_argument(
|
|
110
|
+
"--ba-res",
|
|
111
|
+
dest="ba_resolution",
|
|
112
|
+
type=float,
|
|
113
|
+
metavar="M",
|
|
114
|
+
help="m/px of the images mapprojected for bundle adjustment (default: 1, NAC native).",
|
|
115
|
+
)
|
|
116
|
+
p.add_argument(
|
|
117
|
+
"--reduce",
|
|
118
|
+
type=_reduce,
|
|
119
|
+
metavar="N|auto|none",
|
|
120
|
+
help="ISIS reduce factor of the SfS cubes (default: auto = target / native m/px).",
|
|
121
|
+
)
|
|
122
|
+
p.add_argument("--no-csm", dest="csm", action="store_false", help="Use ISIS cameras instead of CSM.")
|
|
123
|
+
p.add_argument(
|
|
124
|
+
"--local-spice",
|
|
125
|
+
dest="web_spice",
|
|
126
|
+
action="store_false",
|
|
127
|
+
help="spiceinit with local kernels instead of the SPICE web service.",
|
|
128
|
+
)
|
|
129
|
+
p.add_argument(
|
|
130
|
+
"--registration",
|
|
131
|
+
choices=list(REGISTRATION_MODES),
|
|
132
|
+
help="Register cameras to the prior DEM from stereo DEMs, BA points or not at all (default: stereo).",
|
|
133
|
+
)
|
|
134
|
+
p.add_argument("--stereo-pairs", type=int, metavar="N", help="Max stereo pairs (default: 30).")
|
|
135
|
+
p.add_argument(
|
|
136
|
+
"--max-displacement",
|
|
137
|
+
type=float,
|
|
138
|
+
metavar="M",
|
|
139
|
+
help="pc_align --max-displacement for registration (default: 400).",
|
|
140
|
+
)
|
|
141
|
+
p.add_argument(
|
|
142
|
+
"--no-refine", dest="refine", action="store_false", help="Skip the --heights-from-dem refinement."
|
|
143
|
+
)
|
|
144
|
+
p.add_argument(
|
|
145
|
+
"--max-offset-p85",
|
|
146
|
+
type=float,
|
|
147
|
+
metavar="M",
|
|
148
|
+
help="Drop images whose 85th-percentile mapprojected match offset exceeds this (default: 1.8).",
|
|
149
|
+
)
|
|
150
|
+
p.add_argument(
|
|
151
|
+
"--selection",
|
|
152
|
+
choices=list(SELECTION_METHODS),
|
|
153
|
+
help="SfS image selection (default: coverage = 3 Sun directions 30 deg apart per pixel; "
|
|
154
|
+
"kmedoids = Bertone et al. k-medoids seeding + coverage refinement).",
|
|
155
|
+
)
|
|
156
|
+
p.add_argument(
|
|
157
|
+
"--selection-clusters", type=int, metavar="N", help="Medoids for --selection kmedoids (default: 30)."
|
|
158
|
+
)
|
|
159
|
+
p.add_argument(
|
|
160
|
+
"--sfs-cameras",
|
|
161
|
+
choices=list(SFS_CAMERA_MODELS),
|
|
162
|
+
help="Cameras of the reduced SfS cubes: re-mapped CSM (default) or ISIS approximate models.",
|
|
163
|
+
)
|
|
164
|
+
p.add_argument(
|
|
165
|
+
"--cameras-from",
|
|
166
|
+
choices=list(CAMERA_SOURCES),
|
|
167
|
+
help="ba (default) or published: Bertone et al. adjusted cameras, skipping bundle adjustment.",
|
|
168
|
+
)
|
|
169
|
+
p.add_argument("--max-sfs-images", type=int, metavar="N", help="Cap on SfS images.")
|
|
170
|
+
p.add_argument(
|
|
171
|
+
"--max-slew",
|
|
172
|
+
type=float,
|
|
173
|
+
metavar="DEG",
|
|
174
|
+
help="Search cut on |slew| (default: none; see --max-sfs-slew).",
|
|
175
|
+
)
|
|
176
|
+
p.add_argument(
|
|
177
|
+
"--max-sfs-slew",
|
|
178
|
+
type=float,
|
|
179
|
+
metavar="DEG",
|
|
180
|
+
help="Only images with |slew| below this go into SfS (default: 5; slewed images still help BA / stereo).",
|
|
181
|
+
)
|
|
182
|
+
p.add_argument(
|
|
183
|
+
"--max-images",
|
|
184
|
+
type=int,
|
|
185
|
+
metavar="N",
|
|
186
|
+
help="Cap on searched images before download: stereo pairs + illumination coverage (default: none).",
|
|
187
|
+
)
|
|
188
|
+
p.add_argument(
|
|
189
|
+
"--max-pixel-scale", type=float, metavar="M", help="Search cut on the image m/px (default: 1.5)."
|
|
190
|
+
)
|
|
191
|
+
p.add_argument(
|
|
192
|
+
"--ensemble",
|
|
193
|
+
dest="ensemble_members",
|
|
194
|
+
type=int,
|
|
195
|
+
metavar="N",
|
|
196
|
+
help="SfS ensemble members (default: 0).",
|
|
197
|
+
)
|
|
198
|
+
p.add_argument("--seed", type=int, metavar="N", help="Random seed of the ensemble subsets (default: 0).")
|
|
199
|
+
p.add_argument(
|
|
200
|
+
"--region-id", metavar="ID", help="Product name prefix (default: region or work dir name)."
|
|
201
|
+
)
|
|
202
|
+
p.add_argument("--processes", type=int, metavar="N")
|
|
203
|
+
p.add_argument("--threads", type=int, metavar="N")
|
|
204
|
+
for flag, tool in (
|
|
205
|
+
("--ba-opt", "bundle_adjust"),
|
|
206
|
+
("--refine-opt", "refinement bundle_adjust"),
|
|
207
|
+
("--sfs-opt", "parallel_sfs"),
|
|
208
|
+
):
|
|
209
|
+
p.add_argument(
|
|
210
|
+
flag,
|
|
211
|
+
action="append",
|
|
212
|
+
metavar="KEY=VALUE",
|
|
213
|
+
help=f"Extra / overriding {tool} option (repeatable; VALUE none drops it).",
|
|
214
|
+
)
|
|
215
|
+
p.add_argument("--force", action="store_true", help="Recompute steps whose outputs exist.")
|
|
216
|
+
p.add_argument("--dry-run", action="store_true", help="Print the commands without running them.")
|
|
217
|
+
p.add_argument("--data-dir", type=Path, metavar="DIR", help="Override Parquet data directory.")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _overrides(args: argparse.Namespace) -> dict:
|
|
221
|
+
"""Config keys given on the command line."""
|
|
222
|
+
from ..config import KEYS
|
|
223
|
+
from ._utils import parse_options
|
|
224
|
+
|
|
225
|
+
given = vars(args)
|
|
226
|
+
out = {k: v for k, v in given.items() if k in KEYS and k != "sources"}
|
|
227
|
+
for flag, key in _OPTION_FLAGS.items():
|
|
228
|
+
if flag in given:
|
|
229
|
+
out[key] = parse_options(given[flag])
|
|
230
|
+
if args.sources or "from_file" in given:
|
|
231
|
+
out["sources"] = list(args.sources)
|
|
232
|
+
out.setdefault("from_file", None)
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _effective(args: argparse.Namespace):
|
|
237
|
+
from ..config import RunConfig, load_config, merge
|
|
238
|
+
|
|
239
|
+
cfg = load_config(Path(args.config)) if getattr(args, "config", None) else RunConfig()
|
|
240
|
+
return merge(cfg, _overrides(args), "command line")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def run(args: argparse.Namespace) -> int:
|
|
244
|
+
from .. import config as cfgmod
|
|
245
|
+
from ..sfs import SfsPipeline, aoi_projwin, search_images
|
|
246
|
+
from ..tools import ToolError
|
|
247
|
+
from ._utils import print_dry_run, resolve_images
|
|
248
|
+
|
|
249
|
+
dry = getattr(args, "dry_run", False)
|
|
250
|
+
try:
|
|
251
|
+
cfg = _effective(args)
|
|
252
|
+
if getattr(args, "dump_config", False):
|
|
253
|
+
print(cfgmod.dump_config(cfgmod.resolve(cfg, download=False)), end="")
|
|
254
|
+
return 0
|
|
255
|
+
if getattr(args, "check", False):
|
|
256
|
+
return _check(cfgmod.resolve(cfg, download=False), args)
|
|
257
|
+
cfg = cfgmod.resolve(cfg, download=not dry)
|
|
258
|
+
sources = cfgmod.load_sources(cfg)
|
|
259
|
+
except (cfgmod.ConfigError, OSError) as exc:
|
|
260
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
261
|
+
return 1
|
|
262
|
+
if cfg.dem is None:
|
|
263
|
+
print("No prior DEM: pass --dem, or --region for the PGDA LOLA DEM.", file=sys.stderr)
|
|
264
|
+
return 1
|
|
265
|
+
dem = Path(cfg.dem)
|
|
266
|
+
aoi = cfgmod.search_aoi(cfg)
|
|
267
|
+
observations = None
|
|
268
|
+
try:
|
|
269
|
+
if cfg.aoi and cfg.sfs.projwin is None:
|
|
270
|
+
projwin = aoi_projwin(cfg.aoi, dem, snap=cfg.sfs.resolution)
|
|
271
|
+
print(f"AOI -> projwin {' '.join(f'{v:g}' for v in projwin)}")
|
|
272
|
+
cfg = cfgmod.merge(cfg, {"projwin": projwin})
|
|
273
|
+
if not sources and aoi:
|
|
274
|
+
observations = search_images(
|
|
275
|
+
aoi,
|
|
276
|
+
max_slew=cfg.max_slew,
|
|
277
|
+
max_resolution=cfg.max_pixel_scale,
|
|
278
|
+
max_images=cfg.max_images,
|
|
279
|
+
config=cfg.sfs,
|
|
280
|
+
data_dir=cfg.data_dir,
|
|
281
|
+
)
|
|
282
|
+
sources = observations["product_id"].astype(str).tolist()
|
|
283
|
+
print(f"{len(sources)} NAC images selected over the AOI")
|
|
284
|
+
cfg = cfgmod.merge(cfg, {"sources": sources})
|
|
285
|
+
except (ToolError, ValueError, FileNotFoundError) as exc:
|
|
286
|
+
print(exc, file=sys.stderr)
|
|
287
|
+
return 1
|
|
288
|
+
if not sources:
|
|
289
|
+
print(
|
|
290
|
+
"No sources. Pass product IDs, paths, --from-file, or --aoi / --region to search.",
|
|
291
|
+
file=sys.stderr,
|
|
292
|
+
)
|
|
293
|
+
return 1
|
|
294
|
+
|
|
295
|
+
work_dir = Path(cfg.work_dir)
|
|
296
|
+
images = resolve_images(sources, work_dir / "raw", cfg.data_dir, download=not dry)
|
|
297
|
+
pipeline = SfsPipeline(
|
|
298
|
+
work_dir,
|
|
299
|
+
dem,
|
|
300
|
+
cfg.sfs,
|
|
301
|
+
force=getattr(args, "force", False),
|
|
302
|
+
observations=observations,
|
|
303
|
+
aoi=aoi,
|
|
304
|
+
data_dir=cfg.data_dir,
|
|
305
|
+
run_config=cfg,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
if dry:
|
|
310
|
+
print_dry_run(lambda: pipeline.run(images))
|
|
311
|
+
return 0
|
|
312
|
+
out = pipeline.run(images)
|
|
313
|
+
except ToolError as exc:
|
|
314
|
+
print(exc, file=sys.stderr)
|
|
315
|
+
return 1
|
|
316
|
+
print(f"SfS DEM -> {out}")
|
|
317
|
+
if pipeline.removed:
|
|
318
|
+
print(f"{len(pipeline.removed)} images removed (see {work_dir / 'selection' / 'removed.csv'})")
|
|
319
|
+
print(f"{len(pipeline.selected)} SfS images, {len(pipeline.unselected)} unselected")
|
|
320
|
+
return 0
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _check(cfg, args: argparse.Namespace) -> int:
|
|
324
|
+
from .. import config as cfgmod
|
|
325
|
+
from .. import provenance
|
|
326
|
+
from ._utils import resolve_images
|
|
327
|
+
|
|
328
|
+
work_dir = Path(cfg.work_dir)
|
|
329
|
+
sources = cfgmod.load_sources(cfg)
|
|
330
|
+
inputs = None
|
|
331
|
+
if sources and cfg.dem:
|
|
332
|
+
inputs = [*resolve_images(sources, work_dir / "raw", cfg.data_dir, download=False), Path(cfg.dem)]
|
|
333
|
+
try:
|
|
334
|
+
diffs = provenance.check(work_dir, cfg, inputs, checksums=cfg.checksums)
|
|
335
|
+
except FileNotFoundError as exc:
|
|
336
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
337
|
+
return 1
|
|
338
|
+
if not diffs:
|
|
339
|
+
print(f"No differences from the last run in {work_dir / provenance.FILE_NAME}")
|
|
340
|
+
return 0
|
|
341
|
+
print(f"{len(diffs)} differences from the last run in {work_dir / provenance.FILE_NAME}:")
|
|
342
|
+
for d in diffs:
|
|
343
|
+
print(f" {d}")
|
|
344
|
+
return 1
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""sfs-report - validation metrics, figures and an HTML report of an SfS work dir."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
COMMAND = "sfs-report"
|
|
8
|
+
|
|
9
|
+
PGDA_REPORT_KINDS = ("ldec", "toterr", "slp", "slperr", "xyzi")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_parser(sub: argparse._SubParsersAction) -> None:
|
|
13
|
+
p = sub.add_parser(
|
|
14
|
+
COMMAND,
|
|
15
|
+
help="Metrics, figures and an HTML report of an SfS work dir (Bertone et al. 2026 validation).",
|
|
16
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
17
|
+
epilog="""
|
|
18
|
+
Examples
|
|
19
|
+
--------
|
|
20
|
+
lunar sfs-report malapert_crop # work dir files only
|
|
21
|
+
lunar sfs-report malapert_crop --region malapert # + PGDA LDEC, uncertainty maps, LOLA points
|
|
22
|
+
lunar sfs-report malapert_crop --region malapert --fetch-published # + published SDEM
|
|
23
|
+
lunar sfs-report w --toterr Site23_final_adj_5mpp_toterr.tif --lola-points lola.csv -o w/report2
|
|
24
|
+
|
|
25
|
+
Sections (skipped when their inputs are missing): bundle-adjustment residuals before /
|
|
26
|
+
after and match offsets (Fig. 5); removed images; illumination coverage with 0/1/2/>=3
|
|
27
|
+
non-redundant Sun directions (Figs. 3, 6); SfS minus prior DEM, split by LOLA count
|
|
28
|
+
(Table 2); SfS minus LOLA points vs slope (Fig. 7); ensemble uncertainty (Fig. 8);
|
|
29
|
+
SfS - LDEM residuals over the LDEM uncertainty maps (Fig. 9); SfS minus the published SDEM.
|
|
30
|
+
Writes OUTPUT_DIR/report.html (figures embedded), metrics.json, tables/*.csv, figures/*.png.
|
|
31
|
+
""",
|
|
32
|
+
)
|
|
33
|
+
p.add_argument("work_dir", type=Path, metavar="WORK_DIR", help="lunar sfs work directory.")
|
|
34
|
+
p.add_argument(
|
|
35
|
+
"-o",
|
|
36
|
+
"--output-dir",
|
|
37
|
+
type=Path,
|
|
38
|
+
default=None,
|
|
39
|
+
metavar="DIR",
|
|
40
|
+
help="Destination (default: WORK_DIR/report).",
|
|
41
|
+
)
|
|
42
|
+
p.add_argument("--sfs-dem", type=Path, metavar="TIF", help="SfS DEM (default: products GLDELEV or sfs/).")
|
|
43
|
+
p.add_argument("--prior-dem", type=Path, metavar="TIF", help="Prior DEM (default: dem/prior-<RES>m.tif).")
|
|
44
|
+
p.add_argument("--region-id", metavar="ID", help="Product name prefix (default: from config.yaml).")
|
|
45
|
+
p.add_argument(
|
|
46
|
+
"--res", type=float, metavar="M", help="Product resolution label (default: from config.yaml)."
|
|
47
|
+
)
|
|
48
|
+
p.add_argument("--ldec", type=Path, metavar="TIF", help="PGDA LOLA count raster (*_ldec.tif).")
|
|
49
|
+
p.add_argument("--toterr", type=Path, metavar="TIF", help="PGDA elevation uncertainty (*_toterr.tif).")
|
|
50
|
+
p.add_argument("--slperr", type=Path, metavar="TIF", help="PGDA slope uncertainty (*_slperr.tif).")
|
|
51
|
+
p.add_argument("--ldem-slope", type=Path, metavar="TIF", help="PGDA LDEM slope (*_slp.tif).")
|
|
52
|
+
p.add_argument(
|
|
53
|
+
"--lola-points",
|
|
54
|
+
type=Path,
|
|
55
|
+
metavar="CSV",
|
|
56
|
+
help="LOLA points as an ASP CSV (easting, northing, height).",
|
|
57
|
+
)
|
|
58
|
+
p.add_argument("--lola-xyzi", type=Path, metavar="XYZI", help="PGDA LOLA point cloud (*.xyzi).")
|
|
59
|
+
p.add_argument(
|
|
60
|
+
"--published-dem", type=Path, metavar="TIF", help="Published Bertone et al. SDEM (GLDELEV)."
|
|
61
|
+
)
|
|
62
|
+
p.add_argument(
|
|
63
|
+
"--region",
|
|
64
|
+
metavar="REGION",
|
|
65
|
+
help="Artemis III region: download its PGDA files (see --pgda) for the inputs not given.",
|
|
66
|
+
)
|
|
67
|
+
p.add_argument(
|
|
68
|
+
"--pgda",
|
|
69
|
+
nargs="+",
|
|
70
|
+
default=list(PGDA_REPORT_KINDS),
|
|
71
|
+
choices=PGDA_REPORT_KINDS,
|
|
72
|
+
metavar="KIND",
|
|
73
|
+
help=f"PGDA files fetched with --region (default: {' '.join(PGDA_REPORT_KINDS)}).",
|
|
74
|
+
)
|
|
75
|
+
p.add_argument(
|
|
76
|
+
"--fetch-published",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="With --region: extract the published GLDELEV from the Zenodo zip (HTTP range reads).",
|
|
79
|
+
)
|
|
80
|
+
p.add_argument(
|
|
81
|
+
"--reference-dir",
|
|
82
|
+
type=Path,
|
|
83
|
+
default=None,
|
|
84
|
+
metavar="DIR",
|
|
85
|
+
help="Download directory for --region files (default: the reference cache).",
|
|
86
|
+
)
|
|
87
|
+
p.add_argument("--sections", nargs="+", metavar="NAME", help="Only these sections (default: all).")
|
|
88
|
+
p.add_argument(
|
|
89
|
+
"--preview-size",
|
|
90
|
+
type=int,
|
|
91
|
+
default=800,
|
|
92
|
+
metavar="PX",
|
|
93
|
+
help="Longer side of the map figures (default: 800).",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _region_inputs(args: argparse.Namespace) -> dict:
|
|
98
|
+
from .. import reference as ref
|
|
99
|
+
from ..config import pgda_file
|
|
100
|
+
|
|
101
|
+
reg = ref.get_region(args.region)
|
|
102
|
+
names = {"ldec": "ldec", "toterr": "toterr", "slp": "ldem_slope", "slperr": "slperr", "xyzi": "lola_xyzi"}
|
|
103
|
+
out = {}
|
|
104
|
+
for kind in args.pgda:
|
|
105
|
+
if getattr(args, names[kind]) is None:
|
|
106
|
+
out[names[kind]] = pgda_file(
|
|
107
|
+
reg, kind, args.reference_dir / "pgda" if args.reference_dir else None
|
|
108
|
+
)
|
|
109
|
+
if args.fetch_published and args.published_dem is None:
|
|
110
|
+
base = args.reference_dir or ref.REFERENCE_CACHE_DIR
|
|
111
|
+
dest = base / "sdem" / reg.zenodo_file.removesuffix(".zip")
|
|
112
|
+
out["published_dem"] = ref.extract_sdem(reg, ["GLDELEV"], dest)[0]
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run(args: argparse.Namespace) -> int:
|
|
117
|
+
from .. import sfs_report
|
|
118
|
+
|
|
119
|
+
if not args.work_dir.is_dir():
|
|
120
|
+
print(f"Error: {args.work_dir} is not a directory", file=sys.stderr)
|
|
121
|
+
return 1
|
|
122
|
+
given = {
|
|
123
|
+
k: getattr(args, k)
|
|
124
|
+
for k in (
|
|
125
|
+
"sfs_dem",
|
|
126
|
+
"prior_dem",
|
|
127
|
+
"region_id",
|
|
128
|
+
"ldec",
|
|
129
|
+
"toterr",
|
|
130
|
+
"slperr",
|
|
131
|
+
"ldem_slope",
|
|
132
|
+
"lola_points",
|
|
133
|
+
"lola_xyzi",
|
|
134
|
+
"published_dem",
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
try:
|
|
138
|
+
if args.region:
|
|
139
|
+
given.update(_region_inputs(args))
|
|
140
|
+
inputs = sfs_report.ReportInputs.from_work_dir(
|
|
141
|
+
args.work_dir, resolution=args.res, preview_size=args.preview_size, **given
|
|
142
|
+
)
|
|
143
|
+
out = sfs_report.make_report(inputs, args.output_dir, args.sections)
|
|
144
|
+
except (KeyError, ValueError, OSError) as exc:
|
|
145
|
+
print(f"Error: {exc.args[0] if isinstance(exc, KeyError) and exc.args else exc}", file=sys.stderr)
|
|
146
|
+
return 1
|
|
147
|
+
print(f"Report -> {out}")
|
|
148
|
+
return 0
|