Coverage for lunar_toolkit/sfs.py: 91%

719 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 02:58 +0000

1"""Shape-from-shading DEMs from LRO NAC images with ISIS and ASP (Bertone et al. 2026 workflow).""" 

2 

3from __future__ import annotations 

4 

5import csv 

6import dataclasses 

7import importlib 

8import logging 

9import math 

10import re 

11import shutil 

12from collections.abc import Sequence 

13from dataclasses import dataclass, field 

14from pathlib import Path 

15from typing import TYPE_CHECKING, Any 

16 

17import numpy as np 

18 

19from . import asp, isis 

20from .constants import ( 

21 CAMERA_SOURCES, 

22 MOON_RADIUS_KM, 

23 REGISTRATION_MODES, 

24 SELECTION_METHODS, 

25 SFS_CAMERA_MODELS, 

26) 

27from .geometry import load_aoi, ps_xy 

28from .selection import select_kmedoids 

29from .stereo import azimuth_difference 

30from .stereo_dem import stereo_dem 

31from .tools import ToolError, is_dry_run, reuse_existing 

32 

33if TYPE_CHECKING: # pragma: no cover 

34 import pandas as pd 

35 

36 from .config import RunConfig 

37 from .provenance import Provenance 

38 from .workspace import Workspace 

39 

40log = logging.getLogger(__name__) 

41 

42# ASP "Bundle adjustment" recipe for many LRO NAC images under varied illumination 

43# (docs/sfs_usage.rst; --match-first-to-last: near the poles the Sun loops the sky). 

44ASP_BUNDLE_ADJUST: dict[str, Any] = { 

45 "ip_per_image": 30000, 

46 "overlap_limit": 100, 

47 "num_iterations": 100, 

48 "num_passes": 2, 

49 "min_matches": 1, 

50 "max_pairwise_matches": 2000, 

51 "camera_weight": 0, 

52 "robust_threshold": 2, 

53 "tri_weight": 0.05, 

54 "tri_robust_threshold": 0.05, 

55 "remove_outliers_params": "75.0 3.0 100 100", 

56 "save_intermediate_cameras": True, 

57 "match_first_to_last": True, 

58 "forced_triangulation_distance": 100000, 

59 "min_triangulation_angle": 1e-10, 

60 "datum": "D_MOON", 

61} 

62 

63# ASP "Registration refinement" recipe (bundle_adjust --heights-from-dem, reusing the matches). 

64ASP_REFINE: dict[str, Any] = { 

65 "max_pairwise_matches": 2000, 

66 "match_first_to_last": True, 

67 "min_matches": 1, 

68 "num_iterations": 100, 

69 "num_passes": 2, 

70 "camera_weight": 0, 

71 "save_intermediate_cameras": True, 

72 "heights_from_dem_uncertainty": 20.0, 

73 "forced_triangulation_distance": 100000, 

74 "min_triangulation_angle": 1e-10, 

75 "remove_outliers_params": "75.0 3.0 100 100", 

76 "parameter_tolerance": 1e-20, 

77 "datum": "D_MOON", 

78} 

79 

80# ASP "Running SfS in parallel" recipe. 

81ASP_SFS: dict[str, Any] = { 

82 "tile_size": 200, 

83 "padding": 50, 

84 "shadow_threshold": 0.005, 

85 "crop_input_images": True, 

86 "blending_dist": 10, 

87 "min_blend_size": 50, 

88 "allow_borderline_data": True, 

89 "low_light_threshold": 0.03, 

90 "smoothness_weight": 0.08, 

91 "initial_dem_constraint_weight": 0.0025, 

92 "reflectance_type": 1, 

93 "max_iterations": 5, 

94 "save_sparingly": True, 

95} 

96 

97# Bertone et al. (2026): match each image against the next 200 in Sun-azimuth order whose 

98# footprints overlap (``auto_overlap_percent`` → ``--auto-overlap-params "<dem> 15"``), and 

99# soft-anchor cameras at 100 m horizontal / vertical (App. A); intrinsics stay fixed 

100# (ASP's default without --solve-intrinsics). 

101_UNCERTAINTY = {"camera_position_uncertainty": "100,100"} 

102BERTONE_BUNDLE_ADJUST: dict[str, Any] = { 

103 **ASP_BUNDLE_ADJUST, 

104 # The next 200 images in Sun-azimuth order (Bertone et al. 2026, §2). Footprint-based 

105 # pairs (`auto_overlap_percent`) need cameras that fall mostly on the prior DEM, which 

106 # a crop does not satisfy; set it as an override for full-region runs. 

107 "overlap_limit": 200, 

108 **_UNCERTAINTY, 

109} 

110BERTONE_REFINE: dict[str, Any] = {**ASP_REFINE, **_UNCERTAINTY} 

111# §2: mu = 0.05–0.1, lambda = 1e-3, five iterations; App. A: shadow threshold 0.002, albedo fixed. 

112BERTONE_SFS: dict[str, Any] = { 

113 **ASP_SFS, 

114 "shadow_threshold": 0.002, 

115 "smoothness_weight": 0.08, 

116 "initial_dem_constraint_weight": 1e-3, 

117 "max_iterations": 5, 

118} 

119 

120PRESETS: dict[str, dict[str, dict[str, Any]]] = { 

121 "bertone": {"bundle_adjust": BERTONE_BUNDLE_ADJUST, "refine": BERTONE_REFINE, "sfs": BERTONE_SFS}, 

122 "asp": {"bundle_adjust": ASP_BUNDLE_ADJUST, "refine": ASP_REFINE, "sfs": ASP_SFS}, 

123} 

124 

125 

126_OVERLAP_OPTIONS = ("overlap_list", "auto_overlap_params", "auto_overlap_buffer", "overlap_limit") 

127 

128 

129@dataclass 

130class SfsConfig: 

131 """Settings for [`SfsPipeline`][lunar_toolkit.sfs.SfsPipeline]. 

132 

133 Parameters 

134 ---------- 

135 resolution : float 

136 SfS DEM grid spacing (m/px). 

137 projwin : tuple, optional 

138 ``(ulx, uly, lrx, lry)`` crop of the prior DEM, in its projected units. 

139 preset : {"bertone", "asp"} 

140 Option defaults: Bertone et al. (2026) or the ASP SfS recipe (`PRESETS`). 

141 ba_resolution : float, optional 

142 Grid spacing of the images mapprojected for bundle-adjustment matching and 

143 stereo registration (default 1 m, NAC native; None = *resolution*). 

144 reduce : int, "auto" or None 

145 ISIS ``reduce`` factor bringing the cubes to *resolution* before SfS. 

146 ``"auto"``: ``round(resolution / native)`` per image (native = observation 

147 ``pixel_scale``, else *native_resolution*). Reduced cubes use ISIS cameras 

148 (with ``--use-approx-camera-models``); bundle adjustment still uses CSM. 

149 native_resolution : float 

150 Assumed image m/px when the observation table does not give it. 

151 csm : bool 

152 CSM cameras (7–15× faster than ISIS) for mapproject, bundle_adjust and stereo. 

153 web_spice : bool 

154 ``spiceinit web=true`` (USGS SPICE web service) instead of local kernels. 

155 registration : {"stereo", "residuals", "none"} 

156 Registering the adjusted cameras to the prior DEM (translation only). 

157 ``"stereo"``: DEMs from up to *stereo_pairs* pairs, mosaicked and 

158 ``pc_align``-ed (falls back to ``"residuals"`` without pairs); 

159 ``"residuals"``: the bundle-adjustment triangulated points. 

160 stereo_pairs : int 

161 Maximum stereo pairs for ``"stereo"`` registration. 

162 stereo_convergence : (min, max) 

163 Convergence-angle limits (deg) for those pairs. 

164 max_displacement : float 

165 ``pc_align --max-displacement`` (m). 

166 refine : bool 

167 Re-run bundle adjustment on the kept, registered cameras with 

168 ``--heights-from-dem`` (reusing the matches). 

169 max_offset_p85 : float, optional 

170 Drop images whose 85th-percentile mapprojected match offset (m) is larger. 

171 Applied to the DEM-registered, refined cameras (ASP's ``ba_align_ref`` 

172 report); with *refine* off, to the unregistered ``ba/`` cameras. 

173 max_large_residual_fraction : float, optional 

174 Drop images with more than this fraction of reprojection errors over 

175 *large_residual_px* pixels. 

176 min_matches : int 

177 Drop images with fewer inlier matches. 

178 min_images : int 

179 Stop with [`PipelineError`][lunar_toolkit.sfs.PipelineError] when a quality filter leaves fewer 

180 images. 

181 selection : {"coverage", "image_subset", "all"} 

182 SfS image selection: greedy per-pixel illumination coverage 

183 ([`select_by_illumination`][lunar_toolkit.sfs.select_by_illumination]), ASP ``image_subset`` per 

184 Sun-azimuth bin, or all kept images. 

185 max_shadow_fraction : float, optional 

186 Images at least this shadowed (below the SfS shadow threshold) are dropped. 

187 selection_views, selection_azimuth_sep : int, float 

188 Illumination directions wanted per pixel and their minimum separation (deg). 

189 max_sfs_images : int, optional 

190 Cap on the selected images (``"coverage"`` / ``"kmedoids"`` selection). 

191 selection_clusters : int 

192 Medoids seeding ``"kmedoids"`` selection (Bertone et al. 2026, §2: ~30). 

193 sfs_cameras : {"csm", "isis"} 

194 Camera models for SfS on reduced cubes: CSM cameras re-mapped to the reduced 

195 image ([`lunar_toolkit.csm.isis_reduce_with_csm`][lunar_toolkit.csm.isis_reduce_with_csm], as in 

196 Bertone et al. 2026, App. A) or ISIS cameras with ``--use-approx-camera-models``. 

197 cameras_from : {"ba", "published"} 

198 ``"published"`` uses the bundle-adjusted cameras of Bertone et al. (2026) for 

199 *published_region* 

200 ([`lunar_toolkit.reference.published_cameras`][lunar_toolkit.reference.published_cameras]) and skips 

201 bundle adjustment, registration and refinement; images without one are removed. 

202 published_region : str or int, optional 

203 Region of the published cameras (default: from ``region_id`` ``A3NN``). 

204 max_sfs_slew : float, optional 

205 Only images with ``|slew| <`` this (deg) are selected for SfS (Bertone et al. 

206 2026, §2: nadir images); slewed images still join bundle adjustment and stereo 

207 registration. Unknown slews count as nadir; ``None`` disables the cut. 

208 selection_grid : int 

209 Cells along the longer side of the selection grid. 

210 region_id : str, optional 

211 ``ROIID`` for product file names (default: the work directory name). 

212 ensemble_members : int 

213 SfS runs on random subsets of the kept images (sharing at most 40% of their 

214 images) over an *ensemble_crop* m square at the DEM centre, for the ensemble 

215 uncertainty maps (Bertone et al. 2026, §3.2); 0 skips this. 

216 ensemble_size : int, optional 

217 Images per ensemble member (default: as many as selected). 

218 ensemble_crop : float 

219 Side of the ensemble crop (m). 

220 seed : int 

221 Random seed of the ensemble image subsets. 

222 processes, threads : int, optional 

223 Passed to ``parallel_bundle_adjust``, ``parallel_stereo``, ``parallel_sfs`` and ``mapproject``. 

224 bundle_adjust_options, refine_options, sfs_options : dict 

225 Overrides merged onto the preset; a value of ``None`` drops a default. 

226 """ 

227 

228 resolution: float = 5.0 

229 projwin: tuple[float, float, float, float] | None = None 

230 preset: str = "bertone" 

231 ba_resolution: float | None = 1.0 

232 reduce: int | str | None = "auto" 

233 native_resolution: float = 1.0 

234 csm: bool = True 

235 web_spice: bool = True 

236 registration: str = "stereo" 

237 stereo_pairs: int = 30 

238 stereo_convergence: tuple[float, float] = (5.0, 60.0) 

239 max_displacement: float = 400.0 

240 refine: bool = True 

241 max_offset_p85: float | None = 1.8 

242 max_large_residual_fraction: float | None = 0.85 

243 large_residual_px: float = 1.0 

244 min_matches: int = 1 

245 min_images: int = 3 

246 selection: str = "coverage" 

247 max_shadow_fraction: float | None = 0.95 

248 selection_views: int = 3 

249 selection_azimuth_sep: float = 30.0 

250 max_sfs_images: int | None = None 

251 selection_clusters: int = 30 

252 sfs_cameras: str = "csm" 

253 cameras_from: str = "ba" 

254 published_region: str | int | None = None 

255 max_sfs_slew: float | None = 5.0 

256 selection_grid: int = 250 

257 region_id: str | None = None 

258 ensemble_members: int = 0 

259 ensemble_size: int | None = None 

260 ensemble_crop: float = 2500.0 

261 seed: int = 0 

262 processes: int | None = None 

263 threads: int | None = None 

264 bundle_adjust_options: dict[str, Any] = field(default_factory=dict) 

265 refine_options: dict[str, Any] = field(default_factory=dict) 

266 sfs_options: dict[str, Any] = field(default_factory=dict) 

267 

268 def __post_init__(self) -> None: 

269 for name, value, allowed in ( 

270 ("preset", self.preset, tuple(PRESETS)), 

271 ("registration", self.registration, REGISTRATION_MODES), 

272 ("selection", self.selection, SELECTION_METHODS), 

273 ("sfs_cameras", self.sfs_cameras, SFS_CAMERA_MODELS), 

274 ("cameras_from", self.cameras_from, CAMERA_SOURCES), 

275 ): 

276 if value not in allowed: 

277 raise ValueError(f"{name} must be one of {', '.join(allowed)}, not {value!r}") 

278 if isinstance(self.reduce, str) and self.reduce != "auto": 

279 raise ValueError(f"reduce must be an integer, 'auto' or None, not {self.reduce!r}") 

280 

281 def options(self, step: str) -> dict[str, Any]: 

282 """Preset options for ``"bundle_adjust"``, ``"refine"`` or ``"sfs"`` with overrides applied.""" 

283 extra = { 

284 "bundle_adjust": self.bundle_adjust_options, 

285 "refine": self.refine_options, 

286 "sfs": self.sfs_options, 

287 }[step] 

288 return {**PRESETS[self.preset][step], **extra} 

289 

290 

291class PipelineError(RuntimeError): 

292 """The SfS pipeline cannot continue (e.g. too few images left).""" 

293 

294 

295@dataclass 

296class SfsImage: 

297 """One input image: cube, optional CSM camera, Sun angles and derived files.""" 

298 

299 product_id: str 

300 cube: Path 

301 camera: Path | None = None 

302 sun_azimuth: float | None = None 

303 sun_elevation: float | None = None 

304 pixel_scale: float | None = None 

305 mapprojected: Path | None = None 

306 shadow_fraction: float | None = None 

307 slew_angle: float | None = None 

308 

309 

310@dataclass(frozen=True) 

311class RemovedImage: 

312 """An image dropped by a quality filter: *stage* (``ba``, ``refine``, ``selection``) and why.""" 

313 

314 image: SfsImage 

315 stage: str 

316 reason: str 

317 

318 

319class SfsPipeline: 

320 """Build an SfS DEM over a region from NAC images and a prior DEM. 

321 

322 [`run`][lunar_toolkit.sfs.SfsPipeline.run] follows Bertone et al. (2026, PSJ 7:118) and the ASP large-area 

323 SfS recipe: 

324 

325 1. calibrate the images and write CSM cameras (``images/``); 

326 2. crop the prior DEM and resample it to the target grid (``dem/``); 

327 3. sort by Sun azimuth (``sfs --query``); 

328 4. mapproject at native resolution (``prj/``) and bundle-adjust with matches between 

329 the mapprojected images (``ba/``); 

330 5. drop images with no matches or mostly large residuals (``selection/removed.csv``); 

331 6. register the cameras to the prior DEM, translation only, from stereo DEMs 

332 (``stereo/``) or the BA points; apply to all cameras (``align/``, ``ba_align/``); 

333 7. refine with ``--heights-from-dem`` and filter again, now also on mapprojected 

334 match offsets (``ba_refine/``); fewer than ``min_images`` left stops the run; 

335 8. mapproject at the target resolution (``prj_sfs/``), write max-lit QA mosaics, 

336 all images and per 30° Sun-azimuth bin (``qa/``); 

337 9. select an illumination-diverse subset (``selection/``); 

338 10. reduce the selected cubes to the target resolution and run ``parallel_sfs`` (``sfs/``); 

339 11. products (``products/`` via [`lunar_toolkit.sfs_products`][lunar_toolkit.sfs_products], else hillshade 

340 + diff). 

341 

342 Steps whose outputs exist are skipped, so a rerun resumes. 

343 

344 Parameters 

345 ---------- 

346 work_dir : Path 

347 Directory for all intermediate and final files. 

348 prior_dem : Path 

349 Reference terrain, e.g. a LOLA polar stereographic LDEM GeoTIFF. 

350 config : SfsConfig, optional 

351 force : bool 

352 Recompute steps even when their outputs exist. 

353 observations : DataFrame, optional 

354 Observation table ([`lunar_toolkit.observation`][lunar_toolkit.observation] schema) with the input 

355 images, for native pixel scales and stereo pairs; looked up in the local 

356 EDR ``CumIndex`` when needed and not given. 

357 aoi : optional 

358 Region the stereo pairs must overlap in: anything 

359 [`lunar_toolkit.stereo.find_stereo_pairs`][lunar_toolkit.stereo.find_stereo_pairs] takes (dict, 

360 GeoJSON / WKT). 

361 data_dir : Path, optional 

362 ``CumIndex`` data directory. 

363 run_config : RunConfig, optional 

364 The full run settings ([`lunar_toolkit.config`][lunar_toolkit.config]) written to ``config.yaml`` 

365 and ``provenance.json``; default: *work_dir*, *prior_dem*, the sources and *config*. 

366 provenance : bool 

367 Write ``config.yaml`` and append this run to ``provenance.json`` (never in a dry run). 

368 

369 Attributes 

370 ---------- 

371 all_images, images : list of SfsImage 

372 Every input / those passing the quality filters (Sun-azimuth order). 

373 removed : list of RemovedImage 

374 selected, unselected : list of SfsImage 

375 The SfS inputs and the remaining good images (e.g. for an uncertainty ensemble); 

376 both carry ``mapprojected`` (``prj_sfs/``) and ``sun_azimuth``. 

377 dem : Path 

378 The cropped prior DEM on the target grid. 

379 

380 Example 

381 ------- 

382 :: 

383 

384 cfg = SfsConfig(resolution=5, projwin=(-10000, 50000, 0, 40000)) 

385 p = SfsPipeline("work/malapert", "LDEM_87S_5MPP_ADJ.TIF", cfg) 

386 dem = p.run(["raw/M1234567890LE.IMG", ...]) 

387 """ 

388 

389 def __init__( 

390 self, 

391 work_dir: Path, 

392 prior_dem: Path, 

393 config: SfsConfig | None = None, 

394 force: bool = False, 

395 *, 

396 observations: pd.DataFrame | None = None, 

397 aoi: Any = None, 

398 data_dir: Path | None = None, 

399 run_config: RunConfig | None = None, 

400 provenance: bool = True, 

401 ) -> None: 

402 self.work_dir = Path(work_dir) 

403 self.prior_dem = Path(prior_dem) 

404 self.config = config or SfsConfig() 

405 self.force = force 

406 self.observations = observations 

407 self.aoi = aoi 

408 self.data_dir = data_dir 

409 self.all_images: list[SfsImage] = [] 

410 self.images: list[SfsImage] = [] 

411 self.removed: list[RemovedImage] = [] 

412 self.selected: list[SfsImage] = [] 

413 self.unselected: list[SfsImage] = [] 

414 self.dem = self.work_dir / "dem" / f"prior-{self.config.resolution:g}m.tif" 

415 self._registered = False 

416 self.run_config = run_config 

417 self.provenance = provenance 

418 self._prov: Provenance | None = None 

419 

420 def run(self, sources: Sequence[Path]) -> Path: 

421 """All steps; returns the final SfS DEM. 

422 

423 Writes ``config.yaml`` and appends a run to ``provenance.json`` first (see 

424 [`start_provenance`][lunar_toolkit.sfs.SfsPipeline.start_provenance]), then records each step there. 

425 """ 

426 sources = [Path(s) for s in sources] 

427 self.start_provenance(sources) 

428 step = self._step 

429 try: 

430 step("prepare_images", self.prepare_images, sources) 

431 step("prepare_dem", self.prepare_dem) 

432 step("sort_by_sun", self.sort_by_sun) 

433 step( 

434 "mapproject", 

435 self.mapproject, 

436 self.images, 

437 "prj", 

438 self.config.ba_resolution or self.config.resolution, 

439 ) 

440 if self.config.cameras_from == "published": 

441 step("published_cameras", self.use_published_cameras) 

442 else: 

443 step("bundle_adjust", self.bundle_adjust) 

444 step("filter_ba", self.filter_images, self.ba_prefix, "ba") 

445 step("register", self.register) 

446 if self.config.refine: 

447 step("refine", self.refine) 

448 step("filter_refine", self.filter_images, self.refine_prefix, "refine") 

449 step("mapproject_for_sfs", self.mapproject_for_sfs) 

450 step("qa_mosaics", self.qa_mosaics) 

451 step("select_images", self.select_images) 

452 dem = step("shape_from_shading", self.shape_from_shading) 

453 step("products", self.products, dem) 

454 if self.config.ensemble_members: 

455 step("ensemble", self.ensemble) 

456 except BaseException: 

457 if self._prov: 

458 self._prov.finish("error") 

459 raise 

460 if self._prov: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true

461 self._prov.finish("ok") 

462 return dem 

463 

464 def effective_config(self, sources: Sequence[Path] = ()) -> RunConfig: 

465 """`run_config` with this pipeline's work dir, prior DEM and 

466 [`SfsConfig`][lunar_toolkit.sfs.SfsConfig].""" 

467 from .config import RunConfig 

468 

469 if self.run_config is None: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true

470 return RunConfig( 

471 work_dir=self.work_dir, 

472 dem=str(self.prior_dem), 

473 sources=[str(s) for s in sources], 

474 sfs=self.config, 

475 ) 

476 return dataclasses.replace( 

477 self.run_config, work_dir=self.work_dir, dem=str(self.prior_dem), sfs=self.config 

478 ) 

479 

480 def start_provenance(self, sources: Sequence[Path]) -> Path | None: 

481 """Write ``config.yaml`` and start a ``provenance.json`` run; returns the config path. 

482 

483 Inputs are *sources* and the prior DEM. Nothing is written in a dry run 

484 or with ``provenance=False``. 

485 """ 

486 if is_dry_run() or not self.provenance: 

487 return None 

488 from .config import dump_config 

489 from .provenance import Provenance 

490 

491 cfg = self.effective_config(sources) 

492 path = self.work_dir / "config.yaml" 

493 dump_config(cfg, path) 

494 self._prov = Provenance(self.work_dir, cfg, [*sources, self.prior_dem], checksums=cfg.checksums) 

495 return path 

496 

497 def _step(self, name: str, func: Any, *args: Any) -> Any: 

498 if self._prov is None: 

499 return func(*args) 

500 with self._prov.step(name) as entry: 

501 entry["result"] = result = func(*args) 

502 return result 

503 

504 @property 

505 def ba_prefix(self) -> Path: 

506 return self.work_dir / "ba" / "run" 

507 

508 @property 

509 def align_prefix(self) -> Path: 

510 return self.work_dir / "align" / "run" 

511 

512 @property 

513 def ba_align_prefix(self) -> Path: 

514 return self.work_dir / "ba_align" / "run" 

515 

516 @property 

517 def refine_prefix(self) -> Path: 

518 return self.work_dir / "ba_refine" / "run" 

519 

520 @property 

521 def sfs_camera_prefix(self) -> Path: 

522 """Adjustments renamed for the reduced ISIS cubes.""" 

523 return self.work_dir / "sfs_cameras" / "run" 

524 

525 @property 

526 def sfs_prefix(self) -> Path: 

527 return self.work_dir / "sfs" / "run" 

528 

529 @property 

530 def sfs_dem(self) -> Path: 

531 return Path(f"{self.sfs_prefix}-DEM-final.tif") 

532 

533 @property 

534 def camera_prefix(self) -> Path | None: 

535 """Adjustments of the final (registered, refined) cameras; None for published cameras.""" 

536 if self.config.cameras_from == "published": 

537 return None 

538 if self.config.refine: 

539 return self.refine_prefix 

540 return self.ba_align_prefix if self.config.registration != "none" else self.ba_prefix 

541 

542 @property 

543 def shadow_threshold(self) -> float: 

544 return float(self.config.options("sfs").get("shadow_threshold") or 0.0) 

545 

546 def _skip(self, path: Path) -> bool: 

547 return reuse_existing(path, self.force) 

548 

549 def _parallel_opts(self) -> dict[str, Any]: 

550 return {"processes": self.config.processes, "threads": self.config.threads} 

551 

552 def _cameras(self, images: Sequence[SfsImage]) -> list[Path]: 

553 return [im.camera for im in images if im.camera] if self.config.csm else [] 

554 

555 def _map_path(self, subdir: str, im: SfsImage) -> Path: 

556 return self.work_dir / subdir / f"{im.product_id}.map.tif" 

557 

558 def prepare_images(self, sources: Sequence[Path]) -> list[SfsImage]: 

559 """Calibrate EDRs (``.IMG``) or take ready cubes; write CSM cameras.""" 

560 cfg = self.config 

561 scales = _by_id(self.observations, "pixel_scale") 

562 slews = _by_id(self.observations, "slew_angle") 

563 self.all_images = [] 

564 for src in map(Path, sources): 

565 cube, camera = isis.prepare_lronac( 

566 src, self.work_dir / "images", csm=cfg.csm, web=cfg.web_spice, force=self.force 

567 ) 

568 pid = src.name.split(".")[0] 

569 self.all_images.append( 

570 SfsImage( 

571 pid, cube, camera, pixel_scale=scales.get(pid.upper()), slew_angle=slews.get(pid.upper()) 

572 ) 

573 ) 

574 self.images = list(self.all_images) 

575 self._record_observations() 

576 return self.images 

577 

578 def _record_observations(self) -> None: 

579 """Write the given observation rows to the workspace ``images`` table (not in a dry run).""" 

580 if is_dry_run() or self.observations is None or not len(self.observations): 

581 return 

582 from .workspace import WorkspaceError 

583 

584 try: 

585 with self.workspace() as ws: 

586 ws.add_observations(self.observations) 

587 except WorkspaceError as exc: 

588 log.warning("Workspace images not written: %s", exc) 

589 

590 def prepare_dem(self) -> Path: 

591 """Crop the prior DEM to ``projwin`` and resample to the target grid.""" 

592 cfg = self.config 

593 crop = self.work_dir / "dem" / "prior.tif" 

594 if not self._skip(crop): 

595 asp.gdal_translate(self.prior_dem, crop, projwin=cfg.projwin) 

596 if not self._skip(self.dem): 

597 asp.gdal_translate(crop, self.dem, tr=cfg.resolution, r="cubicspline") 

598 return self.dem 

599 

600 def sort_by_sun(self) -> list[SfsImage]: 

601 """Fill Sun azimuth / elevation and sort the images by azimuth. 

602 

603 Bundle adjustment matches neighbours in this order, and ``parallel_sfs`` 

604 expects it. 

605 """ 

606 angles = asp.sun_angles( 

607 self.dem, 

608 [im.cube for im in self.images], 

609 self._cameras(self.images), 

610 out_prefix=self.work_dir / "query" / "run", 

611 ) 

612 by_image = {Path(a.image).name: a for a in angles} 

613 for im in self.images: 

614 a = by_image.get(im.cube.name) 

615 if a: 

616 im.sun_azimuth, im.sun_elevation = a.azimuth, a.elevation 

617 if angles: 

618 self.images.sort(key=lambda im: (im.sun_azimuth is None, im.sun_azimuth or 0.0)) 

619 elif not is_dry_run(): 619 ↛ 620line 619 didn't jump to line 620 because the condition on line 619 was never true

620 log.warning("sfs --query reported no Sun angles; keeping input order") 

621 return self.images 

622 

623 def mapproject( 

624 self, images: Sequence[SfsImage], subdir: str, resolution: float, **options: Any 

625 ) -> list[Path]: 

626 """Mapproject *images* onto the prior DEM (cropped to ``projwin``) into ``<subdir>/``.""" 

627 cfg = self.config 

628 win = cfg.projwin 

629 outs = [] 

630 for im in images: 

631 out = self._map_path(subdir, im) 

632 if not self._skip(out): 

633 asp.mapproject( 

634 self.dem, 

635 im.cube, 

636 out, 

637 im.camera if cfg.csm else None, 

638 tr=resolution, 

639 t_projwin=(win[0], win[3], win[2], win[1]) if win else None, 

640 threads=cfg.threads, 

641 **options, 

642 ) 

643 outs.append(out) 

644 return outs 

645 

646 def bundle_adjust(self) -> Path: 

647 """``parallel_bundle_adjust`` with matches from the mapprojected images. 

648 

649 ``--mapproj-dem`` writes the per-image match-offset report used by 

650 [`filter_images`][lunar_toolkit.sfs.SfsPipeline.filter_images]. 

651 """ 

652 done = Path(f"{self.ba_prefix}-final_residuals_stats.txt") 

653 if self._skip(done): 

654 return self.ba_prefix 

655 opts = self._ba_options(self.config.options("bundle_adjust")) 

656 return asp.bundle_adjust( 

657 [im.cube for im in self.images], 

658 self.ba_prefix, 

659 self._cameras(self.images), 

660 mapprojected=[self._map_path("prj", im) for im in self.images], 

661 dem=self.dem, 

662 parallel=True, 

663 mapproj_dem=self.dem, 

664 **{**self._parallel_opts(), **opts}, 

665 ) 

666 

667 def _ba_options(self, opts: dict[str, Any]) -> dict[str, Any]: 

668 """Preset bundle-adjust options made valid for ASP. 

669 

670 ``auto_overlap_percent`` becomes ``--auto-overlap-params "<dem> <pct>"``, and of 

671 ASP's mutually exclusive pair-selection options only the most specific is kept 

672 (overlap list, footprint overlap, overlap buffer, then consecutive-image limit). 

673 """ 

674 opts = dict(opts) 

675 pct = opts.pop("auto_overlap_percent", None) 

676 if pct is not None and "auto_overlap_params" not in opts: 

677 opts["auto_overlap_params"] = f"{self.dem} {pct:g}" 

678 given = [k for k in _OVERLAP_OPTIONS if opts.get(k) not in (None, False)] 

679 for key in given[1:]: 

680 log.info( 

681 "bundle_adjust: --%s overrides --%s; dropping the latter", 

682 given[0].replace("_", "-"), 

683 key.replace("_", "-"), 

684 ) 

685 del opts[key] 

686 return opts 

687 

688 def workspace(self, read_only: bool = False) -> Workspace: 

689 """The work dir's DuckDB workspace (``lunar.duckdb``, 

690 [`lunar_toolkit.workspace`][lunar_toolkit.workspace]).""" 

691 from .workspace import Workspace 

692 

693 return Workspace(self.work_dir, threads=self.config.threads, read_only=read_only) 

694 

695 def filter_images(self, prefix: Path, stage: str) -> list[RemovedImage]: 

696 """Drop images that ASP's bundle-adjustment reports flag (see 

697 [`flag_images`][lunar_toolkit.sfs.flag_images]). 

698 

699 Loads ``<prefix>-*`` reports (residual stats and raw pixels, match offsets, point 

700 maps, convergence angles) into the workspace as run ``<prefix>``'s directory name 

701 (``ba``, ``ba_refine``) and removes the images of its ``image_rejections`` view, 

702 with this config's thresholds. Removed images are logged and appended to 

703 ``selection/removed.csv``. 

704 

705 At ``ba`` the match-offset rule waits for the refinement when that runs: before 

706 registration the network floats tens of metres off the DEM, so convergent pairs 

707 mapproject apart by ``dh * tan(convergence)`` however well they are tied (ASP: 

708 "do not use this option for any initial evaluation of bundle adjustment"). 

709 """ 

710 cfg = self.config 

711 files = { 

712 k: Path(f"{prefix}-{name}") 

713 for k, name in ( 

714 ("stats", "final_residuals_stats.txt"), 

715 ("raw", "final_residuals_raw_pixels.txt"), 

716 ("offsets", "mapproj_match_offset_stats.txt"), 

717 ) 

718 } 

719 if not any(p.exists() for p in files.values()): 

720 if is_dry_run(): 

721 return [] 

722 raise PipelineError(f"No bundle-adjustment reports under {prefix}") 

723 overrides: dict[str, Any] = {} 

724 if stage == "ba" and cfg.max_offset_p85 is not None: 

725 if cfg.refine: 

726 overrides["max_offset_p85"] = None 

727 else: 

728 log.warning("Match offsets of unregistered cameras overstate misalignment; consider refine") 

729 with self.workspace() as ws: 

730 ws.set_rejection_params(cfg, **overrides) 

731 ws.load_bundle_adjust(prefix, run=Path(prefix).parent.name) 

732 flags = ws.rejections(Path(prefix).parent.name) 

733 removed = [ 

734 RemovedImage(im, stage, flags[im.cube.name]) for im in self.images if im.cube.name in flags 

735 ] 

736 return self._remove(removed) 

737 

738 def _remove(self, removed: list[RemovedImage]) -> list[RemovedImage]: 

739 if not removed: 

740 return [] 

741 gone = {id(r.image) for r in removed} 

742 self.images = [im for im in self.images if id(im) not in gone] 

743 self.removed += removed 

744 for r in removed: 

745 log.info("Removed %s (%s): %s", r.image.product_id, r.stage, r.reason) 

746 log.info("%d images removed at %s, %d remain", len(removed), removed[0].stage, len(self.images)) 

747 _write_csv( 

748 self.work_dir / "selection" / "removed.csv", 

749 ["product_id", "stage", "reason"], 

750 [[r.image.product_id, r.stage, r.reason] for r in self.removed], 

751 ) 

752 if len(self.images) < self.config.min_images: 

753 raise PipelineError( 

754 f"{len(self.images)} images left after the {removed[0].stage} filter, fewer than " 

755 f"min_images={self.config.min_images}; see {self.work_dir / 'selection' / 'removed.csv'}" 

756 ) 

757 return removed 

758 

759 def register(self) -> Path: 

760 """Register the adjusted cameras to the prior DEM (translation only); returns their prefix.""" 

761 mode = self.config.registration 

762 if mode == "none": 

763 return self.ba_prefix 

764 transform = Path(f"{self.align_prefix}-transform.txt") 

765 if not self._skip(transform): 

766 if mode != "stereo" or self._stereo_mosaic() is None: 

767 self._align_residuals() 

768 else: 

769 self._align_stereo() 

770 marker = Path(f"{self.ba_align_prefix}-applied-transform.txt") 

771 if not self._skip(marker): 

772 asp.apply_transform( 

773 [im.cube for im in self.images], 

774 transform, 

775 self.ba_align_prefix, 

776 self._cameras(self.images), 

777 input_adjustments_prefix=self.ba_prefix, 

778 ) 

779 if not is_dry_run(): 779 ↛ 780line 779 didn't jump to line 780 because the condition on line 779 was never true

780 marker.write_text(transform.read_text()) 

781 return self.ba_align_prefix 

782 

783 def _align_residuals(self) -> Path: 

784 return asp.pc_align( 

785 self.dem, 

786 Path(f"{self.ba_prefix}-final_residuals_pointmap.csv"), 

787 self.align_prefix, 

788 max_displacement=self.config.max_displacement, 

789 compute_translation_only=True, 

790 csv_format="1:lon 2:lat 3:height_above_datum", 

791 save_transformed_source_points=True, 

792 ) 

793 

794 def _align_stereo(self) -> Path: 

795 return asp.pc_align( 

796 self.dem, 

797 self.work_dir / "stereo" / "mosaic.tif", 

798 self.align_prefix, 

799 max_displacement=self.config.max_displacement, 

800 compute_translation_only=True, 

801 ) 

802 

803 def stereo_pairs(self) -> pd.DataFrame: 

804 """Up to ``stereo_pairs`` kept-image pairs ranked by 

805 [`find_stereo_pairs`][lunar_toolkit.stereo.find_stereo_pairs].""" 

806 from .stereo import find_stereo_pairs 

807 

808 obs = self._observations() 

809 kept = {im.product_id.upper() for im in self.images} 

810 obs = obs[obs["product_id"].astype(str).str.upper().isin(kept)] 

811 pairs = find_stereo_pairs(obs, aoi=self.aoi, convergence=self.config.stereo_convergence) 

812 return pairs.head(self.config.stereo_pairs) 

813 

814 def _stereo_mosaic(self) -> Path | None: 

815 """Stereo DEMs (triangulated with the ``ba/`` cameras) mosaicked; None without any.""" 

816 cfg = self.config 

817 mosaic = self.work_dir / "stereo" / "mosaic.tif" 

818 if self._skip(mosaic): 818 ↛ 819line 818 didn't jump to line 819 because the condition on line 818 was never true

819 return mosaic 

820 try: 

821 pairs = self.stereo_pairs() 

822 except (OSError, KeyError, ValueError) as exc: 

823 log.warning("No observations for stereo registration (%s); using BA points", exc) 

824 return None 

825 by_id = {im.product_id.upper(): im for im in self.images} 

826 dems = [] 

827 for _, pair in pairs.iterrows(): 

828 a, b = by_id[str(pair["product_id_1"]).upper()], by_id[str(pair["product_id_2"]).upper()] 

829 try: 

830 dem = stereo_dem( 

831 a.cube, 

832 b.cube, 

833 self.work_dir / "stereo" / f"{a.product_id}__{b.product_id}", 

834 resolution=cfg.resolution, 

835 prior_dem=self.dem, 

836 align=False, 

837 csm=cfg.csm, 

838 cameras=(a.camera, b.camera) if cfg.csm and a.camera and b.camera else None, 

839 mapprojected=(self._map_path("prj", a), self._map_path("prj", b)), 

840 bundle_adjust_prefix=self.ba_prefix, 

841 processes=cfg.processes, 

842 threads=cfg.threads, 

843 force=self.force, 

844 ) 

845 except ToolError as exc: 

846 log.warning("Stereo %s / %s failed: %s", a.product_id, b.product_id, exc) 

847 continue 

848 if is_dry_run() or dem.exists(): 848 ↛ 827line 848 didn't jump to line 827 because the condition on line 848 was always true

849 dems.append(dem) 

850 if not dems: 

851 log.warning("No stereo DEMs for registration; using BA points") 

852 return None 

853 return asp.dem_mosaic(dems, mosaic) 

854 

855 def _observations(self) -> pd.DataFrame: 

856 if self.observations is None: 

857 from .lroc.cumindex import CumIndex 

858 

859 ci = CumIndex("edr", data_dir=self.data_dir) 

860 self.observations = ci.to_observations(ci.lookup([im.product_id for im in self.all_images])) 

861 return self.observations 

862 

863 def refine(self) -> Path: 

864 """Bundle-adjust the kept, registered cameras again with ``--heights-from-dem``. 

865 

866 Reuses the ``ba/`` matches (``--match-files-prefix``); ``--mapproj-dem`` gives 

867 the match-offset report for a second [`filter_images`][lunar_toolkit.sfs.SfsPipeline.filter_images]. 

868 """ 

869 done = Path(f"{self.refine_prefix}-final_residuals_stats.txt") 

870 if self._skip(done): 

871 return self.refine_prefix 

872 start = self.ba_align_prefix if self.config.registration != "none" else self.ba_prefix 

873 opts = self._ba_options(self.config.options("refine")) 

874 return asp.bundle_adjust( 

875 [im.cube for im in self.images], 

876 self.refine_prefix, 

877 self._cameras(self.images), 

878 match_files_prefix=self.ba_prefix, 

879 input_adjustments_prefix=start, 

880 heights_from_dem=self.dem, 

881 mapproj_dem=self.dem, 

882 threads=self.config.threads, 

883 **opts, 

884 ) 

885 

886 def mapproject_for_sfs(self) -> list[Path]: 

887 """Mapproject the kept images at the target resolution with the final cameras (``prj_sfs/``).""" 

888 outs = self.mapproject( 

889 self.images, "prj_sfs", self.config.resolution, bundle_adjust_prefix=self.camera_prefix 

890 ) 

891 for im, out in zip(self.images, outs): 

892 im.mapprojected = out 

893 return outs 

894 

895 def qa_mosaics(self, bin_width: float = 30.0) -> dict[str, Path]: 

896 """Max-lit mosaics of all kept images and per Sun-azimuth bin (``qa/``). 

897 

898 Ghosting in these shows misregistered images (Bertone et al. 2026, App. A). 

899 Bins are named by their start, e.g. ``max_lit_az030.tif`` for 30–60°. 

900 """ 

901 qa = self.work_dir / "qa" 

902 maps = [im.mapprojected or self._map_path("prj_sfs", im) for im in self.images] 

903 groups: dict[str, list[Path]] = {"max_lit": maps} 

904 for im, m in zip(self.images, maps): 

905 if im.sun_azimuth is not None: 

906 start = int((im.sun_azimuth % 360.0) // bin_width * bin_width) 

907 groups.setdefault(f"max_lit_az{start:03d}", []).append(m) 

908 out = {} 

909 for name, paths in groups.items(): 

910 out[name] = qa / f"{name}.tif" 

911 if paths and not self._skip(out[name]): 911 ↛ 909line 911 didn't jump to line 909 because the condition on line 911 was always true

912 asp.dem_mosaic(paths, out[name], max=True) 

913 return out 

914 

915 def select_images(self) -> list[SfsImage]: 

916 """Split the kept images into SfS inputs and the rest (``selection/``). 

917 

918 Images at least ``max_shadow_fraction`` shadowed are removed first. Writes 

919 ``selection.csv`` (every kept image: azimuth, shadow fraction, mapprojected path, 

920 selected) plus ``selected.txt`` / ``unselected.txt`` (one product ID per line). 

921 """ 

922 cfg = self.config 

923 out_dir = self.work_dir / "selection" 

924 lit = self._lit_masks() 

925 if lit is not None and cfg.max_shadow_fraction is not None: 

926 shadowed = [ 

927 RemovedImage(im, "selection", f"{im.shadow_fraction:.1%} shadowed") 

928 for im in self.images 

929 if im.shadow_fraction is not None and im.shadow_fraction >= cfg.max_shadow_fraction 

930 ] 

931 gone = {id(r.image) for r in shadowed} 

932 lit = lit[[k for k, im in enumerate(self.images) if id(im) not in gone]] 

933 self._remove(shadowed) 

934 eligible = self._sfs_eligible() 

935 if cfg.selection == "all" or lit is None: 

936 chosen = eligible 

937 elif cfg.selection == "coverage": 

938 az = np.array([np.nan if im.sun_azimuth is None else im.sun_azimuth for im in self.images]) 

939 picked, views = select_by_illumination( 

940 lit[eligible], 

941 az[eligible], 

942 views=cfg.selection_views, 

943 azimuth_sep=cfg.selection_azimuth_sep, 

944 max_images=cfg.max_sfs_images, 

945 ) 

946 chosen = [eligible[k] for k in picked] 

947 covered = lit[eligible].any(axis=0) 

948 if covered.any(): 948 ↛ 978line 948 didn't jump to line 978 because the condition on line 948 was always true

949 log.info( 

950 "Selected %d / %d nadir images; lit cells with >=%d directions: %.1f%%", 

951 len(chosen), 

952 len(eligible), 

953 cfg.selection_views, 

954 100 * np.mean(views[covered] >= cfg.selection_views), 

955 ) 

956 elif cfg.selection == "kmedoids": 

957 az = np.array([np.nan if im.sun_azimuth is None else im.sun_azimuth for im in self.images]) 

958 res = select_kmedoids( 

959 lit[eligible], 

960 az[eligible], 

961 cfg.selection_clusters, 

962 views=cfg.selection_views, 

963 azimuth_sep=cfg.selection_azimuth_sep, 

964 max_images=cfg.max_sfs_images, 

965 seed=cfg.seed, 

966 ) 

967 chosen = sorted(eligible[k] for k in res.chosen) 

968 log.info( 

969 "k-medoids selected %d / %d nadir images (%d medoids); uncovered %.2f%%, poorly lit %.2f%%", 

970 len(chosen), 

971 len(eligible), 

972 len(res.medoids), 

973 100 * res.uncovered, 

974 100 * res.poorly_lit, 

975 ) 

976 else: 

977 chosen = sorted(set(self._image_subset()) & set(eligible)) 

978 chosen_set = set(chosen) 

979 self.selected = [im for k, im in enumerate(self.images) if k in chosen_set] 

980 self.unselected = [im for k, im in enumerate(self.images) if k not in chosen_set] 

981 _write_csv( 

982 out_dir / "selection.csv", 

983 ["product_id", "sun_azimuth", "sun_elevation", "shadow_fraction", "mapprojected", "selected"], 

984 [ 

985 [ 

986 im.product_id, 

987 im.sun_azimuth, 

988 im.sun_elevation, 

989 im.shadow_fraction, 

990 im.mapprojected, 

991 k in chosen_set, 

992 ] 

993 for k, im in enumerate(self.images) 

994 ], 

995 ) 

996 for name, ims in (("selected", self.selected), ("unselected", self.unselected)): 

997 _write_lines(out_dir / f"{name}.txt", [im.product_id for im in ims]) 

998 return self.selected 

999 

1000 def _sfs_eligible(self) -> list[int]: 

1001 """Indices of the kept images allowed into SfS (``|slew| < max_sfs_slew``).""" 

1002 limit = self.config.max_sfs_slew 

1003 if limit is None: 

1004 return list(range(len(self.images))) 

1005 if any(im.slew_angle is None for im in self.images): 

1006 try: 

1007 obs = self._observations() 

1008 except (OSError, KeyError, ValueError) as exc: 

1009 log.warning("No slew angles (%s); all images count as nadir", exc) 

1010 obs = None 

1011 if obs is not None and "slew_angle" in obs: 

1012 slews = dict(zip(obs["product_id"].astype(str).str.upper(), obs["slew_angle"])) 

1013 for im in self.images: 

1014 v = slews.get(im.product_id.upper()) 

1015 if im.slew_angle is None and v is not None and np.isfinite(v): 1015 ↛ 1016line 1015 didn't jump to line 1016 because the condition on line 1015 was never true

1016 im.slew_angle = float(v) 

1017 eligible = [ 

1018 k for k, im in enumerate(self.images) if im.slew_angle is None or abs(im.slew_angle) < limit 

1019 ] 

1020 if len(eligible) < len(self.images): 

1021 log.info( 

1022 "%d slewed images (|slew| >= %g deg) kept out of SfS", len(self.images) - len(eligible), limit 

1023 ) 

1024 return eligible 

1025 

1026 def _lit_masks(self) -> np.ndarray | None: 

1027 """``(images, cells)`` lit mask on a common coarse grid; fills ``shadow_fraction``.""" 

1028 bounds = asp.raster_bounds(self.dem) 

1029 if bounds is None or not self.images: 

1030 return None 

1031 ulx, uly, lrx, lry = bounds 

1032 n = self.config.selection_grid 

1033 w, h = abs(lrx - ulx), abs(uly - lry) 

1034 size = (n, max(1, round(n * h / w))) if w >= h else (max(1, round(n * w / h)), n) 

1035 thr = self.shadow_threshold 

1036 masks = [] 

1037 for im in self.images: 

1038 r = asp.read_raster( 

1039 im.mapprojected or self._map_path("prj_sfs", im), projwin=bounds, outsize=size 

1040 ) 

1041 v = r.data.ravel() if r is not None else np.full(size[0] * size[1], np.nan) 

1042 valid = np.isfinite(v) & (v > -1e30) 

1043 lit = valid & (v > thr) 

1044 im.shadow_fraction = 1.0 - float(lit.sum()) / float(valid.sum()) if valid.any() else 1.0 

1045 masks.append(lit) 

1046 return np.array(masks) 

1047 

1048 def _image_subset(self, bin_width: float = 30.0) -> list[int]: 

1049 """ASP ``image_subset`` in each Sun-azimuth bin; returns indices of the union.""" 

1050 bounds = asp.raster_bounds(self.dem) 

1051 win = (bounds[0], bounds[3], bounds[2], bounds[1]) if bounds else None 

1052 bins: dict[int, list[int]] = {} 

1053 for k, im in enumerate(self.images): 

1054 bins.setdefault(int(((im.sun_azimuth or 0.0) % 360.0) // bin_width), []).append(k) 

1055 by_path = {str(im.mapprojected): k for k, im in enumerate(self.images)} 

1056 chosen: list[int] = [] 

1057 for b, idx in sorted(bins.items()): 

1058 subset = asp.image_subset( 

1059 [self.images[k].mapprojected or self._map_path("prj_sfs", self.images[k]) for k in idx], 

1060 self.work_dir / "selection" / f"image_subset_az{int(b * bin_width):03d}.txt", 

1061 threshold=self.shadow_threshold, 

1062 t_projwin=win, 

1063 threads=self.config.threads, 

1064 ) 

1065 chosen += [by_path[p] for p, _ in subset if p in by_path] 

1066 return sorted(set(chosen)) 

1067 

1068 def reduce_factor(self, im: SfsImage) -> int: 

1069 """ISIS ``reduce`` factor for *im* (1 = keep native resolution).""" 

1070 cfg = self.config 

1071 if cfg.reduce is None: 

1072 return 1 

1073 if cfg.reduce == "auto": 

1074 native = im.pixel_scale or cfg.native_resolution 

1075 return max(1, int(round(cfg.resolution / native))) 

1076 return max(1, int(cfg.reduce)) 

1077 

1078 def sfs_inputs(self, images: Sequence[SfsImage]) -> tuple[list[Path], list[Path], Path | None, bool]: 

1079 """Cubes, cameras and adjustment prefix for SfS on *images*. 

1080 

1081 Cubes needing it are ``reduce``-d to the target resolution (Bertone et al. 2026, 

1082 App. A: SfS's own resampling leaves artifacts). With ``sfs_cameras="csm"`` each 

1083 CSM camera is re-mapped to its reduced cube 

1084 ([`csm.isis_reduce_with_csm`][lunar_toolkit.csm.isis_reduce_with_csm]); otherwise ISIS cameras are 

1085 used. The final ``.adjust`` files (resolution independent) are copied under 

1086 [`sfs_camera_prefix`][lunar_toolkit.sfs.SfsPipeline.sfs_camera_prefix] with the new names; published 

1087 cameras need none. Returns ``(cubes, cameras, prefix, isis_cameras)``. 

1088 """ 

1089 from . import csm 

1090 

1091 cfg = self.config 

1092 factors = [self.reduce_factor(im) for im in images] 

1093 prefix = self.camera_prefix 

1094 if cfg.csm and all(f == 1 for f in factors): 

1095 return [im.cube for im in images], self._cameras(images), prefix, False 

1096 use_csm = cfg.csm and cfg.sfs_cameras == "csm" 

1097 cubes: list[Path] = [] 

1098 cameras: list[Path] = [] 

1099 for im, f in zip(images, factors): 

1100 cube, camera = im.cube, im.camera if use_csm else None 

1101 if f > 1 and use_csm and im.camera is not None: 

1102 cube, camera = csm.isis_reduce_with_csm( 

1103 im.cube, im.camera, f, self.work_dir / "images", force=self.force 

1104 ) 

1105 elif f > 1: 1105 ↛ 1109line 1105 didn't jump to line 1109 because the condition on line 1105 was always true

1106 cube = self.work_dir / "images" / f"{im.product_id}.red{f}.cub" 

1107 if not self._skip(cube): 

1108 isis.reduce(im.cube, cube, f) 

1109 if prefix is not None: 

1110 src = asp.bundle_adjust_file_name(prefix, im.cube, im.camera if cfg.csm else None) 

1111 dst = asp.bundle_adjust_file_name(self.sfs_camera_prefix, cube, camera) 

1112 if not is_dry_run() and not self._skip(dst): 

1113 dst.parent.mkdir(parents=True, exist_ok=True) 

1114 shutil.copyfile(src, dst) 

1115 cubes.append(cube) 

1116 if camera is not None: 

1117 cameras.append(camera) 

1118 out_prefix = self.sfs_camera_prefix if prefix is not None else None 

1119 return cubes, cameras, out_prefix, not use_csm 

1120 

1121 def use_published_cameras(self) -> list[SfsImage]: 

1122 """Replace the cameras with the published Bertone et al. (2026) adjusted ones. 

1123 

1124 Each published camera (a CSM state of the reduced full image) is re-mapped to 

1125 the image of its cube 

1126 ([`reference.published_camera_native`][lunar_toolkit.reference.published_camera_native]) and written 

1127 to ``images/<id>.published.json``; images without one are removed. 

1128 """ 

1129 from . import csm 

1130 from . import reference as ref 

1131 

1132 region = self.config.published_region 

1133 if region is None: 1133 ↛ 1138line 1133 didn't jump to line 1138 because the condition on line 1133 was always true

1134 rid = self.config.region_id or "" 

1135 if not (rid.startswith("A3") and rid[2:].isdigit()): 1135 ↛ 1136line 1135 didn't jump to line 1136 because the condition on line 1135 was never true

1136 raise ValueError("cameras_from: published needs published_region (or region_id A3NN)") 

1137 region = int(rid[2:]) 

1138 out_dir = self.work_dir / "images" 

1139 if is_dry_run(): 1139 ↛ 1143line 1139 didn't jump to line 1143 because the condition on line 1139 was always true

1140 for im in self.images: 

1141 im.camera = out_dir / f"{im.product_id}.published.json" 

1142 return self.images 

1143 published = ref.published_cameras( 

1144 region, self.work_dir / "published", images=[im.product_id for im in self.images] 

1145 ) 

1146 missing = [ 

1147 RemovedImage(im, "published", "no published camera") 

1148 for im in self.images 

1149 if im.product_id not in published 

1150 ] 

1151 self._remove(missing) 

1152 for im in self.images: 

1153 camera = out_dir / f"{im.product_id}.published.json" 

1154 if not self._skip(camera): 

1155 geom = csm.CubeGeometry.from_cube(im.cube) 

1156 native = (geom.alpha_samples, geom.alpha_lines) 

1157 csm.write_camera(ref.published_camera_native(published[im.product_id], native), camera) 

1158 im.camera = camera 

1159 return self.images 

1160 

1161 def shape_from_shading( 

1162 self, 

1163 images: Sequence[SfsImage] | None = None, 

1164 prefix: Path | None = None, 

1165 dem: Path | None = None, 

1166 **options: Any, 

1167 ) -> Path: 

1168 """``parallel_sfs`` on the target-grid prior DEM; returns ``<prefix>-DEM-final.tif``. 

1169 

1170 *images* default to `selected` (else all kept images), *prefix* to 

1171 ``sfs/run`` and *dem* (input DEM, e.g. a crop) to `dem`. *options* 

1172 override the configured SfS options. 

1173 """ 

1174 images = list(images) if images is not None else (self.selected or self.images) 

1175 prefix = Path(prefix) if prefix else self.sfs_prefix 

1176 out = Path(f"{prefix}-DEM-final.tif") 

1177 if self._skip(out): 1177 ↛ 1178line 1177 didn't jump to line 1178 because the condition on line 1177 was never true

1178 return out 

1179 cubes, cameras, ba_prefix, isis_cameras = self.sfs_inputs(images) 

1180 opts: dict[str, Any] = {**self.config.options("sfs"), **self._parallel_opts()} 

1181 if isis_cameras: 1181 ↛ 1182line 1181 didn't jump to line 1182 because the condition on line 1181 was never true

1182 opts["use_approx_camera_models"] = True 

1183 opts.update(options) 

1184 return asp.sfs( 

1185 dem or self.dem, cubes, prefix, cameras, parallel=True, bundle_adjust_prefix=ba_prefix, **opts 

1186 ) 

1187 

1188 def products(self, dem: Path) -> dict[str, Path]: 

1189 """Standard products via 

1190 [`lunar_toolkit.sfs_products.make_products`][lunar_toolkit.sfs_products.make_products] when available. 

1191 

1192 Otherwise (module missing or its dependencies not installed) a hillshade of the 

1193 SfS DEM and its difference from the prior. 

1194 """ 

1195 try: 

1196 products = importlib.import_module(f"{__package__}.sfs_products") 

1197 except ImportError as exc: 

1198 log.info("sfs_products unavailable (%s); writing hillshade and diff only", exc) 

1199 else: 

1200 height_error = Path(f"{self.sfs_prefix}-height-error.tif") 

1201 return products.make_products( 

1202 dem, 

1203 self.dem, 

1204 self.work_dir / "products", 

1205 [ 

1206 (im.mapprojected, im.sun_azimuth, im.pixel_scale) 

1207 for im in self.selected 

1208 if im.mapprojected and im.sun_azimuth is not None 

1209 ], 

1210 region_id=self.config.region_id or self.work_dir.name, 

1211 resolution=self.config.resolution, 

1212 height_error=height_error if height_error.exists() else None, 

1213 shadow_threshold=self.shadow_threshold, 

1214 force=self.force, 

1215 ) 

1216 out = {"dem": dem} 

1217 shade = Path(f"{self.sfs_prefix}-DEM-final_hillshade.tif") 

1218 out["hillshade"] = shade if self._skip(shade) else asp.hillshade(dem, shade) 

1219 diff_prefix = Path(f"{self.sfs_prefix}-prior") 

1220 diff = Path(f"{diff_prefix}-diff.tif") 

1221 out["diff"] = diff if self._skip(diff) else asp.geodiff(dem, self.dem, diff_prefix) 

1222 return out 

1223 

1224 def ensemble(self) -> dict[str, Path]: 

1225 """SfS ensemble over a central crop and its spread (``ensemble/``, ``products/``). 

1226 

1227 Members are [`ensemble_subsets`][lunar_toolkit.sfs_products.ensemble_subsets] of the kept 

1228 images; returns [`ensemble_uncertainty`][lunar_toolkit.sfs_products.ensemble_uncertainty]'s maps. 

1229 """ 

1230 from . import sfs_products 

1231 

1232 cfg = self.config 

1233 bounds = asp.raster_bounds(self.dem) or cfg.projwin 

1234 if bounds is None: 1234 ↛ 1235line 1234 didn't jump to line 1235 because the condition on line 1234 was never true

1235 log.warning("Ensemble skipped: DEM extent unknown (dry run without projwin)") 

1236 return {} 

1237 ens = self.work_dir / "ensemble" 

1238 crop = ens / "prior.tif" 

1239 if not self._skip(crop): 1239 ↛ 1242line 1239 didn't jump to line 1242 because the condition on line 1239 was always true

1240 centre = ((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2) 

1241 asp.gdal_translate(self.dem, crop, projwin=sfs_products.crop_window(*centre, cfg.ensemble_crop)) 

1242 size = min(cfg.ensemble_size or len(self.selected) or len(self.images), len(self.images)) 

1243 subsets = sfs_products.ensemble_subsets(self.images, cfg.ensemble_members, size, seed=cfg.seed) 

1244 members = [ 

1245 self.shape_from_shading(subset, ens / f"m{k:02d}" / "run", dem=crop) 

1246 for k, subset in enumerate(subsets) 

1247 ] 

1248 _write_lines(ens / "members.txt", [" ".join(im.product_id for im in subset) for subset in subsets]) 

1249 if is_dry_run(): 1249 ↛ 1251line 1249 didn't jump to line 1251 because the condition on line 1249 was always true

1250 return {} 

1251 return sfs_products.ensemble_uncertainty( 

1252 members, 

1253 self.work_dir / "products", 

1254 region_id=cfg.region_id or self.work_dir.name, 

1255 resolution=cfg.resolution, 

1256 reference=crop, 

1257 force=self.force, 

1258 ) 

1259 

1260 

1261def flag_images( 

1262 residual_stats: dict[str, asp.ResidualStats], 

1263 residual_norms: dict[str, np.ndarray], 

1264 offsets: dict[str, asp.MatchOffsetStats], 

1265 *, 

1266 max_offset_p85: float | None = 1.8, 

1267 max_large_residual_fraction: float | None = 0.85, 

1268 large_residual_px: float = 1.0, 

1269 min_matches: int = 1, 

1270) -> dict[str, str]: 

1271 """Images to drop after bundle adjustment (Bertone et al. 2026, §2), keyed by file name. 

1272 

1273 An image is flagged when it has fewer than *min_matches* residuals or match offsets, 

1274 an 85th-percentile mapprojected match offset over *max_offset_p85* m, or more than 

1275 *max_large_residual_fraction* of its reprojection errors over *large_residual_px*. 

1276 The value is the reason. The rules are the workspace's ``image_rejections`` view 

1277 ([`lunar_toolkit.workspace`][lunar_toolkit.workspace]), evaluated in an in-memory database. 

1278 """ 

1279 from .workspace import flag_images_sql 

1280 

1281 return flag_images_sql( 

1282 residual_stats, 

1283 residual_norms, 

1284 offsets, 

1285 max_offset_p85=max_offset_p85, 

1286 max_large_residual_fraction=max_large_residual_fraction, 

1287 large_residual_px=large_residual_px, 

1288 min_matches=min_matches, 

1289 ) 

1290 

1291 

1292def select_by_illumination( 

1293 lit: np.ndarray, 

1294 azimuth: np.ndarray, 

1295 *, 

1296 views: int = 3, 

1297 azimuth_sep: float = 30.0, 

1298 max_images: int | None = None, 

1299 min_gain: int = 1, 

1300) -> tuple[list[int], np.ndarray]: 

1301 """Greedy per-pixel illumination-diverse image selection on a raster grid. 

1302 

1303 The raster analogue of 

1304 [`lunar_toolkit.selection.select_for_coverage`][lunar_toolkit.selection.select_for_coverage], after 

1305 Bertone et al. (2026, §2): a lit cell is *helped* by an image lighting it if it has fewer than *views* 

1306 illumination directions so far and the image's Sun azimuth is at least *azimuth_sep* from each of them. 

1307 Each step takes the image helping most cells, until none helps *min_gain* cells or *max_images* are 

1308 chosen. 

1309 

1310 Parameters 

1311 ---------- 

1312 lit : (n_images, n_cells) bool array 

1313 azimuth : (n_images,) array 

1314 Sun azimuths (deg); NaN never helps. 

1315 

1316 Returns 

1317 ------- 

1318 (chosen, count) 

1319 Image indices in pick order, and the directions reached per cell. 

1320 """ 

1321 lit = np.asarray(lit, bool) 

1322 az = np.asarray(azimuth, float) 

1323 n, cells = lit.shape 

1324 dirs = np.full((cells, views), np.nan) 

1325 count = np.zeros(cells, int) 

1326 available = np.isfinite(az) 

1327 chosen: list[int] = [] 

1328 while available.any() and (max_images is None or len(chosen) < max_images): 

1329 open_cells = count < views 

1330 best, best_cells = -1, np.empty(0, int) 

1331 for k in np.flatnonzero(available): 

1332 idx = np.flatnonzero(lit[k] & open_cells) 

1333 if len(idx) <= len(best_cells): 

1334 continue 

1335 d = azimuth_difference(dirs[idx], az[k]) 

1336 idx = idx[np.all(np.isnan(d) | (d >= azimuth_sep), axis=1)] 

1337 if len(idx) > len(best_cells): 

1338 best, best_cells = int(k), idx 

1339 if best < 0 or len(best_cells) < min_gain: 

1340 break 

1341 dirs[best_cells, count[best_cells]] = az[best] 

1342 count[best_cells] += 1 

1343 available[best] = False 

1344 chosen.append(best) 

1345 return chosen, count 

1346 

1347 

1348def prefilter_candidates( 

1349 df: pd.DataFrame, max_slew: float | None = None, max_resolution: float | None = 1.5 

1350) -> pd.DataFrame: 

1351 """Images finer than *max_resolution* m/px (and, with *max_slew*, ``|slew| <`` it). 

1352 

1353 Works on ``CumIndex`` rows (``SLEW_ANGLE``, ``RESOLUTION``) or observation rows 

1354 (``slew_angle``, ``pixel_scale``). Rows with unknown values are dropped. The 

1355 nadir cut of Bertone et al. (2026, §2) belongs to SfS selection 

1356 (`SfsConfig.max_sfs_slew`), so searches keep slewed images by default: they 

1357 give the stereo convergence the registration step needs. 

1358 """ 

1359 import pandas as pd 

1360 

1361 keep = pd.Series(True, index=df.index) 

1362 slew_col = next((c for c in ("SLEW_ANGLE", "slew_angle") if c in df), None) 

1363 if max_slew is not None: 

1364 if slew_col: 1364 ↛ 1367line 1364 didn't jump to line 1367 because the condition on line 1364 was always true

1365 keep &= pd.to_numeric(df[slew_col], errors="coerce").abs() < max_slew 

1366 else: 

1367 log.warning("No slew column; slew filter skipped") 

1368 res_col = next((c for c in ("RESOLUTION", "pixel_scale", "PIXEL_SCALE") if c in df), None) 

1369 if max_resolution is not None and res_col: 1369 ↛ 1371line 1369 didn't jump to line 1371 because the condition on line 1369 was always true

1370 keep &= pd.to_numeric(df[res_col], errors="coerce") < max_resolution 

1371 return df[keep] 

1372 

1373 

1374def cap_candidates( 

1375 obs: pd.DataFrame, 

1376 aoi: Any, 

1377 max_images: int, 

1378 *, 

1379 max_sfs_slew: float | None = 5.0, 

1380 stereo_pairs: int = 30, 

1381 stereo_convergence: tuple[float, float] = (5.0, 60.0), 

1382 views: int = 3, 

1383 azimuth_sep: float = 30.0, 

1384) -> pd.DataFrame: 

1385 """At most *max_images* candidate images, chosen before anything is downloaded. 

1386 

1387 Keeps both images of the best stereo pairs (up to half the budget; they feed the 

1388 registration step), then nadir images (``|slew| < max_sfs_slew``) chosen for 

1389 illumination coverage over *aoi* ([`select_for_coverage`][lunar_toolkit.selection.select_for_coverage]: 

1390 *views* directions at least *azimuth_sep* apart per cell), then the finest remaining 

1391 nadir images. Returns the chosen rows in their original order. 

1392 """ 

1393 from .selection import select_for_coverage 

1394 

1395 if len(obs) <= max_images: 

1396 return obs 

1397 ids = obs["product_id"].astype(str).str.upper() 

1398 chosen: list[str] = [] 

1399 

1400 def take(new: Sequence[str], limit: int) -> None: 

1401 for pid in new: 

1402 if len(chosen) >= limit: 

1403 return 

1404 if pid not in chosen: 

1405 chosen.append(pid) 

1406 

1407 if stereo_pairs: 1407 ↛ 1415line 1407 didn't jump to line 1415 because the condition on line 1407 was always true

1408 pairs = _candidate_pairs(obs, aoi, stereo_convergence) 

1409 stereo_ids = [ 

1410 str(v).upper() 

1411 for pair in pairs.head(stereo_pairs).itertuples() 

1412 for v in (pair.product_id_1, pair.product_id_2) 

1413 ] 

1414 take(stereo_ids, max_images // 2) 

1415 slew = _numeric(obs.get("slew_angle")) 

1416 nadir = obs[(slew.abs() < max_sfs_slew) | slew.isna()] if max_sfs_slew is not None else obs 

1417 nadir = nadir[~nadir["product_id"].astype(str).str.upper().isin(chosen)] 

1418 if len(nadir): 1418 ↛ 1425line 1418 didn't jump to line 1425 because the condition on line 1418 was always true

1419 cover = select_for_coverage( 

1420 nadir, aoi, azimuth_sep_deg=azimuth_sep, views_per_cell=views, max_images=max_images - len(chosen) 

1421 ) 

1422 take(cover["product_id"].astype(str).str.upper().tolist(), max_images) 

1423 rest = nadir.assign(_res=_numeric(nadir.get("pixel_scale"))).sort_values("_res") 

1424 take(rest["product_id"].astype(str).str.upper().tolist(), max_images) 

1425 log.info("Capped %d candidates to %d (stereo pairs + illumination coverage)", len(obs), len(chosen)) 

1426 return obs[ids.isin(set(chosen)).to_numpy()] 

1427 

1428 

1429def _candidate_pairs(obs: pd.DataFrame, aoi: Any, convergence: tuple[float, float]) -> pd.DataFrame: 

1430 from .stereo import find_stereo_pairs 

1431 from .workspace import PAIRS_ENGINE_THRESHOLD, find_stereo_pairs_duckdb, spatial_available 

1432 

1433 if len(obs) >= PAIRS_ENGINE_THRESHOLD and spatial_available(): 

1434 return find_stereo_pairs_duckdb(obs, aoi, convergence=convergence) 

1435 return find_stereo_pairs(obs, aoi=aoi, convergence=convergence) 

1436 

1437 

1438def _numeric(values: Any) -> pd.Series: 

1439 import pandas as pd 

1440 

1441 if values is None: 1441 ↛ 1442line 1441 didn't jump to line 1442 because the condition on line 1441 was never true

1442 return pd.Series(dtype=float) 

1443 return pd.to_numeric(values, errors="coerce") 

1444 

1445 

1446def search_images( 

1447 aoi: Any, 

1448 *, 

1449 max_slew: float | None = None, 

1450 max_resolution: float | None = 1.5, 

1451 max_images: int | None = None, 

1452 config: SfsConfig | None = None, 

1453 data_dir: Path | None = None, 

1454) -> pd.DataFrame: 

1455 """NAC EDRs over *aoi* from the local ``CumIndex``, pre-filtered and capped; observation schema. 

1456 

1457 *config* supplies the SfS slew cut and stereo settings used by 

1458 [`cap_candidates`][lunar_toolkit.sfs.cap_candidates]. 

1459 """ 

1460 from .lroc.cumindex import CumIndex 

1461 

1462 cfg = config or SfsConfig() 

1463 ci = CumIndex("edr", data_dir=data_dir) 

1464 df = ci.search(aoi=aoi, nac_only=True, wkt=True) 

1465 kept = prefilter_candidates(df, max_slew, max_resolution) 

1466 log.info("%d NAC images over the AOI, %d after the resolution cut", len(df), len(kept)) 

1467 obs = ci.to_observations(kept) 

1468 if max_images is not None: 

1469 obs = cap_candidates( 

1470 obs, 

1471 aoi, 

1472 max_images, 

1473 max_sfs_slew=cfg.max_sfs_slew, 

1474 stereo_pairs=cfg.stereo_pairs if cfg.registration == "stereo" else 0, 

1475 stereo_convergence=cfg.stereo_convergence, 

1476 views=cfg.selection_views, 

1477 azimuth_sep=cfg.selection_azimuth_sep, 

1478 ) 

1479 return obs 

1480 

1481 

1482@dataclass(frozen=True) 

1483class PolarStereographic: 

1484 """A polar stereographic projection (sphere, true scale at the pole) from a PROJ string.""" 

1485 

1486 hemisphere: str 

1487 lon_0: float = 0.0 

1488 radius: float = MOON_RADIUS_KM * 1000.0 

1489 x_0: float = 0.0 

1490 y_0: float = 0.0 

1491 

1492 @classmethod 

1493 def from_proj4(cls, proj4: str) -> PolarStereographic: 

1494 """Parse e.g. ``+proj=stere +lat_0=-90 +lon_0=0 +k=1 +x_0=0 +y_0=0 +R=1737400 +units=m``. 

1495 

1496 Raises 

1497 ------ 

1498 ValueError 

1499 For anything else (other projections, oblique stereographic, scale ≠ 1, non-metre units). 

1500 """ 

1501 p: dict[str, str] = dict(re.findall(r"\+(\w+)(?:=(\S+))?", proj4)) 

1502 lat_0 = float(p.get("lat_0", "nan")) 

1503 k = float(p.get("k", p.get("k_0", "1"))) 

1504 lat_ts = float(p["lat_ts"]) if "lat_ts" in p else lat_0 

1505 sphere_a = p.get("a") if p.get("a") == p.get("b", p.get("a")) else None 

1506 radius = float(p.get("R") or sphere_a or "nan") 

1507 if ( 

1508 p.get("proj") not in ("stere", "ups") 

1509 or not math.isclose(abs(lat_0), 90.0) 

1510 or not math.isclose(k, 1.0) 

1511 or not math.isclose(lat_ts, lat_0) 

1512 or p.get("units", "m") != "m" 

1513 or not math.isfinite(radius) 

1514 ): 

1515 raise ValueError( 

1516 f"Expected a spherical polar stereographic projection in metres, got {proj4!r}; " 

1517 "pass --projwin in the DEM's own units instead" 

1518 ) 

1519 return cls( 

1520 "S" if lat_0 < 0 else "N", 

1521 float(p.get("lon_0", 0.0)), 

1522 radius, 

1523 float(p.get("x_0", 0.0)), 

1524 float(p.get("y_0", 0.0)), 

1525 ) 

1526 

1527 def xy(self, lat: float, lon: float) -> tuple[float, float]: 

1528 x, y = ps_xy(lat, lon - self.lon_0, self.hemisphere) 

1529 scale = self.radius / (MOON_RADIUS_KM * 1000.0) 

1530 return x * scale + self.x_0, y * scale + self.y_0 

1531 

1532 

1533def aoi_projwin(aoi: Any, dem: Path, snap: float | None = None) -> tuple[float, float, float, float]: 

1534 """``(ulx, uly, lrx, lry)`` bounding *aoi* in the polar stereographic projection of *dem*. 

1535 

1536 *aoi* is anything [`lunar_toolkit.geometry.load_aoi`][lunar_toolkit.geometry.load_aoi] takes (GeoJSON / 

1537 WKT file, WKT string). The window is widened to multiples of *snap* (m) when given. 

1538 """ 

1539 proj = PolarStereographic.from_proj4(asp.srs_proj4(dem)) 

1540 pts = [proj.xy(lat, lon) for poly in load_aoi(aoi).polygons for lon, lat in poly[0]] 

1541 xs, ys = [p[0] for p in pts], [p[1] for p in pts] 

1542 x0, x1, y0, y1 = min(xs), max(xs), min(ys), max(ys) 

1543 if snap: 1543 ↛ 1546line 1543 didn't jump to line 1546 because the condition on line 1543 was always true

1544 x0, y0 = math.floor(x0 / snap) * snap, math.floor(y0 / snap) * snap 

1545 x1, y1 = math.ceil(x1 / snap) * snap, math.ceil(y1 / snap) * snap 

1546 return x0, y1, x1, y0 

1547 

1548 

1549def _by_id(observations: pd.DataFrame | None, column: str) -> dict[str, float]: 

1550 """Finite values of a numeric observation *column* keyed by upper-case product ID.""" 

1551 if observations is None or column not in observations: 

1552 return {} 

1553 import pandas as pd 

1554 

1555 values = pd.to_numeric(observations[column], errors="coerce") 

1556 ids = observations["product_id"].astype(str).str.upper() 

1557 return {i: float(v) for i, v in zip(ids, values) if np.isfinite(v)} 

1558 

1559 

1560def _write_csv(path: Path, header: list[str], rows: list[list[Any]]) -> None: 

1561 if is_dry_run(): 

1562 return 

1563 path.parent.mkdir(parents=True, exist_ok=True) 

1564 with open(path, "w", newline="") as f: 

1565 w = csv.writer(f) 

1566 w.writerow(header) 

1567 w.writerows([["" if v is None else v for v in r] for r in rows]) 

1568 

1569 

1570def _write_lines(path: Path, lines: list[str]) -> None: 

1571 if not is_dry_run(): 

1572 path.parent.mkdir(parents=True, exist_ok=True) 

1573 path.write_text("".join(f"{ln}\n" for ln in lines))