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

1"""sfs - shape-from-shading DEM from LROC NAC images (ISIS + ASP).""" 

2 

3import argparse 

4import sys 

5from pathlib import Path 

6 

7from ..constants import CAMERA_SOURCES, REGISTRATION_MODES, SELECTION_METHODS, SFS_CAMERA_MODELS 

8 

9COMMAND = "sfs" 

10 

11_OPTION_FLAGS = {"ba_opt": "bundle_adjust_options", "refine_opt": "refine_options", "sfs_opt": "sfs_options"} 

12 

13 

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 

24 

25 

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 

45 

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. 

50 

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.") 

218 

219 

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 

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 

236def _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 

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 

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: 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 

321 

322 

323def _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