Coverage for lunar_toolkit/cli/sfs.py: 88%
153 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 02:58 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 02:58 +0000
1"""sfs - shape-from-shading DEM from LROC NAC images (ISIS + ASP)."""
3import argparse
4import sys
5from pathlib import Path
7from ..constants import CAMERA_SOURCES, REGISTRATION_MODES, SELECTION_METHODS, SFS_CAMERA_MODELS
9COMMAND = "sfs"
11_OPTION_FLAGS = {"ba_opt": "bundle_adjust_options", "refine_opt": "refine_options", "sfs_opt": "sfs_options"}
14def _reduce(value: str):
15 low = value.lower()
16 if low == "auto": 16 ↛ 17line 16 didn't jump to line 17 because the condition on line 16 was never true
17 return "auto"
18 if low == "none": 18 ↛ 20line 18 didn't jump to line 20 because the condition on line 18 was always true
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
26def 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="""
33Examples
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
46Config 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
48settings to WORK_DIR/config.yaml and appends its environment, tool versions,
49input checksums and per-step commands to WORK_DIR/provenance.json.
51Steps (Bertone et al. 2026, PSJ 7:118): [search + slew/resolution cut + download] ->
52calibrate + 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
55again -> mapproject at --res -> max-lit QA mosaics (all + 30 deg Sun bins) -> select images ->
56reduce cubes to --res -> parallel_sfs -> products.
57Re-running resumes: steps whose outputs exist are skipped (--force to redo).
58Needs 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.")
220def _overrides(args: argparse.Namespace) -> dict:
221 """Config keys given on the command line."""
222 from ..config import KEYS
223 from ._utils import parse_options
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
236def _effective(args: argparse.Namespace):
237 from ..config import RunConfig, load_config, merge
239 cfg = load_config(Path(args.config)) if getattr(args, "config", None) else RunConfig()
240 return merge(cfg, _overrides(args), "command line")
243def run(args: argparse.Namespace) -> int:
244 from .. import config as cfgmod
245 from ..sfs import PipelineError, SfsPipeline, aoi_projwin, search_images
246 from ..tools import ToolError
247 from ._utils import print_dry_run, resolve_images
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
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 )
308 try:
309 if dry: 309 ↛ 312line 309 didn't jump to line 312 because the condition on line 309 was always true
310 print_dry_run(lambda: pipeline.run(images))
311 return 0
312 out = pipeline.run(images)
313 except (ToolError, PipelineError) 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
323def _check(cfg, args: argparse.Namespace) -> int:
324 from .. import config as cfgmod
325 from .. import provenance
326 from ._utils import resolve_images
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