Coverage for lunar_toolkit/asp.py: 94%

190 statements  

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

1"""Ames Stereo Pipeline wrappers: positional inputs as arguments, other options as keywords.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import re 

7import time 

8from collections.abc import Sequence 

9from dataclasses import dataclass 

10from pathlib import Path 

11from typing import Any 

12 

13import numpy as np 

14 

15from .tools import ToolError, asp_options, gdal_options, is_dry_run, run 

16 

17 

18def _paths(items: Sequence[Any]) -> list[str]: 

19 return [str(p) for p in items] 

20 

21 

22def _parent(prefix: Path) -> None: 

23 if not is_dry_run(): 

24 Path(prefix).parent.mkdir(parents=True, exist_ok=True) 

25 

26 

27def mapproject( 

28 dem: Path, image: Path, out: Path, camera: Path | None = None, *, cwd: Path | None = None, **options: Any 

29) -> Path: 

30 """Orthorectify *image* onto *dem* (``tr=``, ``t_projwin=``, ``bundle_adjust_prefix=``).""" 

31 _parent(out) 

32 cams = [camera] if camera else [] 

33 run("mapproject", [*asp_options(options), dem, image, *cams, out], cwd=cwd) 

34 return Path(out) 

35 

36 

37def bundle_adjust( 

38 images: Sequence[Path], 

39 out_prefix: Path, 

40 cameras: Sequence[Path] = (), 

41 *, 

42 mapprojected: Sequence[Path] | None = None, 

43 dem: Path | None = None, 

44 parallel: bool = False, 

45 cwd: Path | None = None, 

46 **options: Any, 

47) -> Path: 

48 """Run ``bundle_adjust`` (or ``parallel_bundle_adjust`` with *parallel*). 

49 

50 *mapprojected* (same order as *images*) and the *dem* they were projected 

51 onto become ``--mapprojected-data``, which finds matches between images of 

52 very different illumination. Returns *out_prefix*. 

53 

54 Raises [`ToolError`][lunar_toolkit.tools.ToolError] when the run writes no fresh 

55 ``.adjust`` file for some image: ASP exits 0 even when it finds nothing to 

56 optimize ("No points to optimize ... Cannot continue"). 

57 """ 

58 _parent(out_prefix) 

59 if mapprojected is not None: 

60 options["mapprojected_data"] = " ".join(_paths([*mapprojected, *([dem] if dem else [])])) 

61 tool = "parallel_bundle_adjust" if parallel else "bundle_adjust" 

62 start = time.time() 

63 run(tool, [*_paths(images), *_paths(cameras), *asp_options(options), "-o", out_prefix], cwd=cwd) 

64 if not is_dry_run(): 

65 prefix = Path(cwd or ".") / out_prefix 

66 cams: Sequence[Path | None] = cameras if len(cameras) == len(images) else [None] * len(images) 

67 missing = [ 

68 p 

69 for p in (bundle_adjust_file_name(prefix, im, cam) for im, cam in zip(images, cams)) 

70 if not p.is_file() or p.stat().st_mtime < start - 2 

71 ] 

72 if missing: 

73 raise ToolError( 

74 f"{tool} wrote no adjustment for {len(missing)} of {len(images)} images " 

75 f"(e.g. {missing[0].name}); see {out_prefix}-log-bundle_adjust-*.txt" 

76 ) 

77 return Path(out_prefix) 

78 

79 

80def apply_transform( 

81 images: Sequence[Path], 

82 transform: Path, 

83 out_prefix: Path, 

84 cameras: Sequence[Path] = (), 

85 *, 

86 input_adjustments_prefix: Path | None = None, 

87 cwd: Path | None = None, 

88 **options: Any, 

89) -> Path: 

90 """Apply a ``pc_align`` 4×4 transform to (adjusted) cameras, without optimizing.""" 

91 return bundle_adjust( 

92 images, 

93 out_prefix, 

94 cameras, 

95 initial_transform=transform, 

96 input_adjustments_prefix=input_adjustments_prefix, 

97 apply_initial_transform_only=True, 

98 cwd=cwd, 

99 **options, 

100 ) 

101 

102 

103def sfs( 

104 dem: Path, 

105 images: Sequence[Path], 

106 out_prefix: Path, 

107 cameras: Sequence[Path] = (), 

108 *, 

109 parallel: bool = False, 

110 cwd: Path | None = None, 

111 **options: Any, 

112) -> Path: 

113 """Run ``sfs`` (or ``parallel_sfs``); returns ``<out_prefix>-DEM-final.tif``.""" 

114 _parent(out_prefix) 

115 tool = "parallel_sfs" if parallel else "sfs" 

116 run( 

117 tool, ["-i", dem, *_paths(images), *_paths(cameras), *asp_options(options), "-o", out_prefix], cwd=cwd 

118 ) 

119 return Path(f"{out_prefix}-DEM-final.tif") 

120 

121 

122@dataclass(frozen=True) 

123class SunAngles: 

124 image: str 

125 azimuth: float 

126 elevation: float 

127 

128 

129_SUN_RE = re.compile(r"Sun azimuth and elevation for: (?P<image>\S+) are (?P<az>\S+) and (?P<el>\S+) degrees") 

130 

131 

132def parse_sun_angles(text: str) -> list[SunAngles]: 

133 """Parse ``sfs --query`` output into per-image Sun azimuth / elevation.""" 

134 return [SunAngles(m["image"], float(m["az"]), float(m["el"])) for m in _SUN_RE.finditer(text)] 

135 

136 

137def sun_angles( 

138 dem: Path, 

139 images: Sequence[Path], 

140 cameras: Sequence[Path] = (), 

141 *, 

142 out_prefix: Path = Path("sfs_query/run"), 

143 cwd: Path | None = None, 

144 **options: Any, 

145) -> list[SunAngles]: 

146 """Sun azimuth (from north) and elevation per image at the DEM centre. 

147 

148 Runs ``sfs --query``; results come back in input order. Sort by azimuth 

149 before bundle adjustment and SfS. 

150 """ 

151 _parent(out_prefix) 

152 res = run( 

153 "sfs", 

154 ["-i", dem, *_paths(images), *_paths(cameras), "--query", *asp_options(options), "-o", out_prefix], 

155 cwd=cwd, 

156 capture=True, 

157 ) 

158 return parse_sun_angles(res.stdout or "") 

159 

160 

161def pc_align( 

162 reference: Path, 

163 source: Path, 

164 out_prefix: Path, 

165 *, 

166 max_displacement: float, 

167 cwd: Path | None = None, 

168 **options: Any, 

169) -> Path: 

170 """Align *source* to *reference*; returns ``<out_prefix>-transform.txt``.""" 

171 _parent(out_prefix) 

172 run( 

173 "pc_align", 

174 ["--max-displacement", max_displacement, *asp_options(options), reference, source, "-o", out_prefix], 

175 cwd=cwd, 

176 ) 

177 return Path(f"{out_prefix}-transform.txt") 

178 

179 

180def point2dem(cloud: Path, *, cwd: Path | None = None, **options: Any) -> Path: 

181 """Grid a point cloud; returns ``<prefix>-DEM.tif`` (prefix from ``o=`` or the cloud).""" 

182 run("point2dem", [*asp_options(options), cloud], cwd=cwd) 

183 prefix = options.get("o") or options.get("output_prefix") 

184 if prefix is None: 

185 stem = str(cloud) 

186 prefix = stem[: -len("-PC.tif")] if stem.endswith("-PC.tif") else str(Path(stem).with_suffix("")) 

187 return Path(f"{prefix}-DEM.tif") 

188 

189 

190def stereo( 

191 left: Path, 

192 right: Path, 

193 out_prefix: Path, 

194 left_camera: Path | None = None, 

195 right_camera: Path | None = None, 

196 *, 

197 dem: Path | None = None, 

198 parallel: bool = True, 

199 cwd: Path | None = None, 

200 **options: Any, 

201) -> Path: 

202 """Run ``parallel_stereo`` (or ``stereo``); returns ``<out_prefix>-PC.tif``. 

203 

204 Pass *dem* when the inputs are mapprojected onto it. 

205 """ 

206 _parent(out_prefix) 

207 cams = [c for c in (left_camera, right_camera) if c] 

208 tail = [dem] if dem else [] 

209 tool = "parallel_stereo" if parallel else "stereo" 

210 run(tool, [*asp_options(options), left, right, *cams, out_prefix, *tail], cwd=cwd) 

211 return Path(f"{out_prefix}-PC.tif") 

212 

213 

214def dem_mosaic(inputs: Sequence[Path], out: Path, *, cwd: Path | None = None, **options: Any) -> Path: 

215 """Mosaic DEMs or images (``max=True`` for a maximally-lit mosaic).""" 

216 _parent(out) 

217 run("dem_mosaic", [*_paths(inputs), *asp_options(options), "-o", out], cwd=cwd) 

218 return Path(out) 

219 

220 

221def hillshade( 

222 dem: Path, 

223 out: Path | None = None, 

224 *, 

225 azimuth: float = 300, 

226 elevation: float = 20, 

227 cwd: Path | None = None, 

228 **options: Any, 

229) -> Path: 

230 out = Path(out) if out else Path(dem).with_name(Path(dem).stem + "_hillshade.tif") 

231 run( 

232 "hillshade", 

233 [dem, "--azimuth", azimuth, "--elevation", elevation, *asp_options(options), "-o", out], 

234 cwd=cwd, 

235 ) 

236 return out 

237 

238 

239def geodiff(dem1: Path, dem2: Path, out_prefix: Path, *, cwd: Path | None = None, **options: Any) -> Path: 

240 """``dem1 - dem2`` (``absolute=True`` for |diff|); returns ``<out_prefix>-diff.tif``.""" 

241 _parent(out_prefix) 

242 run("geodiff", [*asp_options(options), dem1, dem2, "-o", out_prefix], cwd=cwd) 

243 return Path(f"{out_prefix}-diff.tif") 

244 

245 

246def gdal_translate( 

247 src: Path, 

248 dst: Path, 

249 *, 

250 projwin: Sequence[float] | None = None, 

251 tr: float | None = None, 

252 r: str | None = None, 

253 cwd: Path | None = None, 

254 **options: Any, 

255) -> Path: 

256 """Crop (``projwin`` = ulx uly lrx lry) and / or resample (``tr`` metres, ``r`` method).""" 

257 _parent(dst) 

258 opts = { 

259 "projwin": projwin, 

260 "tr": (tr, tr) if tr is not None else None, 

261 "r": r, 

262 "of": "GTiff", 

263 "co": "COMPRESS=LZW", 

264 **options, 

265 } 

266 run("gdal_translate", [*gdal_options(opts), src, dst], cwd=cwd) 

267 return Path(dst) 

268 

269 

270def bundle_adjust_file_name(prefix: Path, image: Path, camera: Path | None = None) -> Path: 

271 """``<prefix>-<stem>.adjust`` as ASP names it (stem of *camera*, else *image*, up to the last dot).""" 

272 name = Path(camera or image).name 

273 stem = name[: name.rfind(".")] if "." in name else name 

274 return Path(f"{prefix}-{stem}.adjust") 

275 

276 

277@dataclass(frozen=True) 

278class MatchOffsetStats: 

279 """One row of ``<prefix>-mapproj_match_offset_stats.txt`` (metres; -1 when no matches).""" 

280 

281 image: str 

282 p25: float 

283 p50: float 

284 p75: float 

285 p85: float 

286 p95: float 

287 count: int 

288 

289 

290def read_mapproj_offset_stats(path: Path) -> dict[str, MatchOffsetStats]: 

291 """Parse ``bundle_adjust --mapproj-dem`` per-image offsets, keyed by image file name. 

292 

293 Format (ASP ``saveMapprojOffsets``): ``# image_name 25% 50% 75% 85% 95% count``. 

294 """ 

295 out = {} 

296 for line in Path(path).read_text().splitlines(): 

297 parts = line.split() 

298 if not parts or parts[0].startswith("#") or len(parts) < 7: 

299 continue 

300 p25, p50, p75, p85, p95 = map(float, parts[1:6]) 

301 out[Path(parts[0]).name] = MatchOffsetStats(parts[0], p25, p50, p75, p85, p95, int(float(parts[6]))) 

302 return out 

303 

304 

305@dataclass(frozen=True) 

306class ResidualStats: 

307 """One row of ``<prefix>-final_residuals_stats.txt`` (pixels).""" 

308 

309 image: str 

310 mean: float 

311 median: float 

312 count: int 

313 

314 

315def read_residual_stats(path: Path) -> dict[str, ResidualStats]: 

316 """Parse per-camera reprojection errors (``# Image, mean, median, count``), keyed by file name.""" 

317 out = {} 

318 for line in Path(path).read_text().splitlines(): 

319 parts = [p.strip() for p in line.split(",")] 

320 if not parts[0] or parts[0].startswith("#") or len(parts) < 4: 

321 continue 

322 out[Path(parts[0]).name] = ResidualStats(parts[0], float(parts[1]), float(parts[2]), int(parts[3])) 

323 return out 

324 

325 

326def read_residual_norms(path: Path) -> dict[str, np.ndarray]: 

327 """Per-image reprojection error norms (pixels) from ``<prefix>-final_residuals_raw_pixels.txt``. 

328 

329 The file has, per image, a ``name, count`` line followed by *count* ``ex, ey`` lines. 

330 """ 

331 out: dict[str, np.ndarray] = {} 

332 lines = Path(path).read_text().splitlines() 

333 i = 0 

334 while i < len(lines): 

335 head = [p.strip() for p in lines[i].split(",")] 

336 i += 1 

337 if len(head) != 2 or not head[0] or head[0].startswith("#"): 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 continue 

339 n = int(head[1]) 

340 xy = np.array([[float(v) for v in ln.split(",")] for ln in lines[i : i + n]]).reshape(-1, 2) 

341 out[Path(head[0]).name] = np.hypot(xy[:, 0], xy[:, 1]) 

342 i += n 

343 return out 

344 

345 

346def image_subset( 

347 images: Sequence[Path], out: Path, *, threshold: float, cwd: Path | None = None, **options: Any 

348) -> list[tuple[str, int]]: 

349 """Run ``image_subset``; returns ``(image, contributing pixels)`` best first. 

350 

351 Writes the image list to ``<out>.in.txt``; ``t_projwin=(xmin, ymin, xmax, ymax)`` limits 

352 the evaluated extent. Empty in a dry run. 

353 """ 

354 _parent(out) 

355 image_list = Path(f"{out}.in.txt") 

356 if not is_dry_run(): 356 ↛ 358line 356 didn't jump to line 358 because the condition on line 356 was always true

357 image_list.write_text("".join(f"{p}\n" for p in images)) 

358 run( 

359 "image_subset", 

360 ["--image-list", image_list, "--threshold", threshold, *asp_options(options), "-o", out], 

361 cwd=cwd, 

362 ) 

363 if is_dry_run() or not Path(out).exists(): 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true

364 return [] 

365 return [ 

366 (parts[0], int(float(parts[1]))) 

367 for parts in (ln.split() for ln in Path(out).read_text().splitlines()) 

368 if len(parts) >= 2 and not parts[0].startswith("#") 

369 ] 

370 

371 

372def raster_info(path: Path) -> dict: 

373 """``gdalinfo -json`` of *path* (empty in a dry run).""" 

374 res = run("gdalinfo", ["-json", path], capture=True) 

375 return json.loads(res.stdout) if res.stdout else {} 

376 

377 

378def raster_bounds(path: Path) -> tuple[float, float, float, float] | None: 

379 """``(ulx, uly, lrx, lry)`` of a north-up raster, or None in a dry run.""" 

380 info = raster_info(path) 

381 if not info: 381 ↛ 383line 381 didn't jump to line 383 because the condition on line 381 was always true

382 return None 

383 x0, dx, _, y0, _, dy = info["geoTransform"] 

384 nx, ny = info["size"] 

385 return x0, y0, x0 + nx * dx, y0 + ny * dy 

386 

387 

388def srs_proj4(path: Path) -> str: 

389 """PROJ string of a raster's spatial reference (``gdalsrsinfo -o proj4``; empty in a dry run).""" 

390 res = run("gdalsrsinfo", ["-o", "proj4", path], capture=True) 

391 return (res.stdout or "").strip().strip("'") 

392 

393 

394@dataclass(frozen=True) 

395class Raster: 

396 """Band 1 of a north-up raster: ``data[row, col]`` (NaN = nodata), top-left corner and pixel size.""" 

397 

398 data: np.ndarray 

399 x0: float 

400 y0: float 

401 dx: float 

402 dy: float 

403 

404 

405def read_raster( 

406 path: Path, 

407 *, 

408 projwin: Sequence[float] | None = None, 

409 outsize: tuple[int, int] | None = None, 

410 r: str = "average", 

411) -> Raster | None: 

412 """Read (a resampled window of) a raster through ``gdal_translate -of AAIGrid /vsistdout/``. 

413 

414 *projwin* (``ulx uly lrx lry``) may extend past the raster (filled with nodata) and 

415 *outsize* ``(columns, rows)`` resamples with *r*, so several images read with the 

416 same window share one grid. Meant for small grids; returns None in a dry run. 

417 """ 

418 opts = { 

419 "q": True, 

420 "of": "AAIGrid", 

421 "ot": "Float32", 

422 "b": 1, 

423 "projwin": projwin, 

424 "outsize": outsize, 

425 "r": r if outsize else None, 

426 } 

427 res = run("gdal_translate", [*gdal_options(opts), path, "/vsistdout/"], capture=True) 

428 return parse_aaigrid(res.stdout) if res.stdout else None 

429 

430 

431def parse_aaigrid(text: str) -> Raster: 

432 """Parse ESRI ASCII grid text (as GDAL's AAIGrid driver writes it).""" 

433 lines = text.splitlines() 

434 head: dict[str, float] = {} 

435 k = 0 

436 while k < len(lines) and lines[k][:1].isalpha(): 

437 key, value = lines[k].split()[:2] 

438 head[key.lower()] = float(value) 

439 k += 1 

440 nx, ny = int(head["ncols"]), int(head["nrows"]) 

441 dx = head.get("dx", head.get("cellsize")) 

442 dy = head.get("dy", head.get("cellsize")) 

443 if dx is None or dy is None: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true

444 raise ValueError("AAIGrid header lacks cellsize / dx / dy") 

445 x0 = head["xllcorner"] if "xllcorner" in head else head["xllcenter"] - dx / 2 

446 yll = head["yllcorner"] if "yllcorner" in head else head["yllcenter"] - dy / 2 

447 # GDAL also writes the .prj (WKT) to /vsistdout/ after the cells. 

448 data = np.array(" ".join(lines[k:]).split()[: nx * ny], dtype=float).reshape(ny, nx) 

449 if "nodata_value" in head: 449 ↛ 451line 449 didn't jump to line 451 because the condition on line 449 was always true

450 data[data == head["nodata_value"]] = np.nan 

451 return Raster(data, x0, yll + ny * dy, dx, -dy)