Coverage for lunar_toolkit/lroc/nac.py: 51%
357 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 02:58 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 02:58 +0000
1"""LROC NAC images: metadata, download and the ISIS + GDAL processing pipeline."""
3from __future__ import annotations
5import logging
6from collections.abc import Callable, Sequence
7from concurrent.futures import ThreadPoolExecutor, as_completed
8from pathlib import Path
9from typing import Any, Generic, TypeVar, overload
11import matplotlib.image as mpimg
12import matplotlib.pyplot as plt
13import pandas as pd
15from ..constants import DEFAULT_PROJECTION, GEOTIFF_OPTIONS, nac_id, parse_nac_id
16from ..downloader import download_file, url_filename
17from ..isis import calibrate_lronac, map_project
18from ..tools import is_dry_run, reuse_existing, run
19from .cumindex import CumIndex
21log = logging.getLogger(__name__)
23_T = TypeVar("_T")
24_V = TypeVar("_V")
26# Ordered (column_name, display_label) pairs for metadata display.
27# Used by metadata_items(), the CLI query command, and the GUI.
28METADATA_LABELS: list[tuple[str, str]] = [
29 ("PRODUCT_ID", "Product"),
30 ("DATA_SET_ID", "PDS dataset name"),
31 ("VOLUME_ID", "PDS volume name"),
32 ("INSTRUMENT_HOST_ID", "Instrument host"),
33 ("INSTRUMENT_ID", "Instrument"),
34 ("ORIGINAL_PRODUCT_ID", "Original product"),
35 ("PRODUCT_VERSION_ID", "Product version"),
36 ("MISSION_PHASE_NAME", "Mission phase name"),
37 ("RATIONALE_DESC", "Rationale desc"),
38 ("DATA_QUALITY_ID", "Data quality"),
39 ("NAC_PREROLL_START_TIME", "NAC preroll start time"),
40 ("START_TIME", "Start time"),
41 ("STOP_TIME", "Stop time"),
42 ("SPACECRAFT_CLOCK_PARTITION", "SC clock partition"),
43 ("NAC_SPACECRAFT_CLOCK_PREROLL_COUNT", "NAC SC clock preroll count"),
44 ("SPACECRAFT_CLOCK_START_COUNT", "SC clock start count"),
45 ("SPACECRAFT_CLOCK_STOP_COUNT", "SC clock stop count"),
46 ("TARGET_NAME", "Target name"),
47 ("ORBIT_NUMBER", "Orbit number"),
48 ("SLEW_ANGLE", "Slew angle"),
49 ("ORBIT_NODE", "LRO node crossing"),
50 ("LRO_FLIGHT_DIRECTION", "LRO flight direction"),
51 ("NAC_LINE_EXPOSURE_DURATION", "NAC line exposure duration"),
52 ("NAC_FRAME_ID", "NAC frame"),
53 ("NAC_DAC_RESET", "NAC DAC reset"),
54 ("NAC_CHANNEL_A_OFFSET", "NAC channel A offset"),
55 ("NAC_CHANNEL_B_OFFSET", "NAC channel B offset"),
56 ("INSTRUMENT_MODE_CODE", "Instrument mode code"),
57 ("COMPAND_SELECT_CODE", "Compand select code"),
58 ("MODE_COMPRESSION", "Mode compression"),
59 ("MODE_TEST", "Mode test"),
60 ("NAC_TEMPERATURE_SCS", "NAC temperature SCS"),
61 ("NAC_TEMPERATURE_FPA", "NAC temperature FPA"),
62 ("NAC_TEMPERATURE_FPGA", "NAC temperature FPGA"),
63 ("NAC_TEMPERATURE_TELESCOPE", "NAC temperature telescope"),
64 ("IMAGE_LINES", "Image lines"),
65 ("LINE_SAMPLES", "Line samples"),
66 ("SAMPLE_BITS", "Sample bits"),
67 ("SCALED_PIXEL_WIDTH", "Scaled pixel width"),
68 ("SCALED_PIXEL_HEIGHT", "Scaled pixel height"),
69 ("RESOLUTION", "Resolution"),
70 ("EMISSION_ANGLE", "Emission angle"),
71 ("INCIDENCE_ANGLE", "Incidence angle"),
72 ("PHASE_ANGLE", "Phase angle"),
73 ("NORTH_AZIMUTH", "North azimuth"),
74 ("SUB_SOLAR_AZIMUTH", "Sub solar azimuth"),
75 ("SUB_SOLAR_LATITUDE", "Sub solar latitude"),
76 ("SUB_SOLAR_LONGITUDE", "Sub solar longitude"),
77 ("SUB_SPACECRAFT_LATITUDE", "Sub spacecraft latitude"),
78 ("SUB_SPACECRAFT_LONGITUDE", "Sub spacecraft longitude"),
79 ("SOLAR_DISTANCE", "Solar distance"),
80 ("SOLAR_LONGITUDE", "Solar longitude"),
81 ("CENTER_LATITUDE", "Center latitude"),
82 ("CENTER_LONGITUDE", "Center longitude"),
83 ("UPPER_RIGHT_LATITUDE", "Upper right latitude"),
84 ("UPPER_RIGHT_LONGITUDE", "Upper right longitude"),
85 ("LOWER_RIGHT_LATITUDE", "Lower right latitude"),
86 ("LOWER_RIGHT_LONGITUDE", "Lower right longitude"),
87 ("LOWER_LEFT_LATITUDE", "Lower left latitude"),
88 ("LOWER_LEFT_LONGITUDE", "Lower left longitude"),
89 ("UPPER_LEFT_LATITUDE", "Upper left latitude"),
90 ("UPPER_LEFT_LONGITUDE", "Upper left longitude"),
91 ("SPACECRAFT_ALTITUDE", "Spacecraft altitude"),
92 ("TARGET_CENTER_DISTANCE", "Target center distance"),
93 ("PDS_URL", "PDS URL"),
94]
97def _normalize_nac_id(product_id: str) -> str:
98 """Full, upper-case NAC ID (short ``M…L`` → EDR ``M…LE``); other IDs just upper-cased."""
99 return nac_id(product_id) or product_id.strip().upper()
102def _raw(value: Any) -> Any:
103 return value
106class IndexColumn(Generic[_V]):
107 """Read-only [`NacImage`][lunar_toolkit.lroc.nac.NacImage] attribute holding one CUMINDEX column.
109 The value is converted by *cast*; ``None`` when the column is missing
110 or null.
111 """
113 def __init__(self, column: str, cast: Callable[[Any], _V]) -> None:
114 self.column = column
115 self.cast = cast
117 @overload
118 def __get__(self, obj: None, owner: type | None = None) -> IndexColumn[_V]: ...
120 @overload
121 def __get__(self, obj: NacImage, owner: type | None = None) -> _V | None: ...
123 def __get__(self, obj: NacImage | None, owner: type | None = None) -> IndexColumn[_V] | _V | None:
124 if obj is None: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
125 return self
126 value = obj._get(self.column)
127 return None if value is None else self.cast(value)
129 def __repr__(self) -> str:
130 return f"IndexColumn({self.column!r}, {getattr(self.cast, '__name__', self.cast)})"
133class NacImage:
134 """A single LROC NAC (or WAC) image identified by its PDS product ID.
136 Product ID anatomy::
138 M 1096757719 L E
139 │ └── type: E = EDR, C = CDR
140 └──── side: L = left, R = right
142 Short base IDs (``M1096757719L``) are automatically normalized to EDR
143 (``M1096757719LE``). The correct CumIndex dataset is auto-detected from
144 the type suffix, so no manual dataset selection is needed.
146 Parameters
147 ----------
148 product_id : str
149 Full or short NAC/WAC product ID, e.g. ``"M1524360231LE"``,
150 ``"M1524360231L"``, or ``"M1524360231LC"``.
151 cumindex : CumIndex, optional
152 Pre-built CumIndex instance to reuse. If None, one is constructed
153 lazily on first metadata access (dataset auto-detected from suffix).
154 _row : pd.Series, optional
155 Pre-populated metadata row from a bulk ``CumIndex.get_images()`` call.
156 Avoids a per-image database round-trip.
157 """
159 def __init__(
160 self,
161 product_id: str,
162 cumindex: CumIndex | None = None,
163 _row: pd.Series | None = None,
164 ) -> None:
165 self._product_id = _normalize_nac_id(product_id)
166 self._cumindex = cumindex
167 self.__row = _row # pre-populated; None triggers lazy DB load
169 # Identity
170 @property
171 def product_id(self) -> str:
172 return self._product_id
174 @property
175 def side(self) -> str | None:
176 """``"L"`` for left NAC, ``"R"`` for right NAC, ``None`` for WAC."""
177 parts = parse_nac_id(self._product_id)
178 return parts[1] if parts else None
180 @property
181 def product_type(self) -> str | None:
182 """``"edr"`` for LE/RE, ``"cdr"`` for LC/RC, ``None`` for WAC."""
183 parts = parse_nac_id(self._product_id)
184 return parts[2] if parts else None
186 @property
187 def edr_id(self) -> str | None:
188 """Product ID for the EDR version of this observation (e.g. ``M...LE``)."""
189 return nac_id(self._product_id, product_type="edr")
191 @property
192 def cdr_id(self) -> str | None:
193 """Product ID for the CDR version of this observation (e.g. ``M...LC``)."""
194 return nac_id(self._product_id, product_type="cdr")
196 @property
197 def is_left(self) -> bool:
198 return self.side == "L"
200 @property
201 def is_right(self) -> bool:
202 return self.side == "R"
204 @property
205 def is_nac(self) -> bool:
206 return self.side is not None
208 # Stereo pair
209 @property
210 def pair_id(self) -> str | None:
211 """Product ID of the stereo sibling (L↔R, same type suffix). None for WAC."""
212 if self.side is None: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 return None
214 return nac_id(self._product_id, side="R" if self.side == "L" else "L")
216 @property
217 def has_pair(self) -> bool:
218 """True if the stereo sibling exists in the EDR index (DB check)."""
219 pid = self.pair_id
220 if pid is None:
221 return False
222 result = self._get_cumindex().query(
223 f"SELECT count(*) AS n FROM cumindex WHERE upper(trim(PRODUCT_ID)) = '{pid}'"
224 )
225 return bool(result["n"].iloc[0] > 0)
227 @property
228 def pair(self) -> NacImage | None:
229 """The stereo sibling as a NacImage, or None if not in the index."""
230 pid = self.pair_id
231 if pid is None:
232 return None
233 col = self._get_cumindex().get_images([pid])
234 return col[0] if len(col) > 0 else None
236 # Metadata (lazy, DB-backed)
237 @property
238 def _row(self) -> pd.Series:
239 if self.__row is None: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true
240 df = self._get_cumindex().lookup([self._product_id])
241 if df.empty:
242 ds = self.product_type or "edr"
243 raise ValueError(f"Product {self._product_id!r} not found in {ds.upper()} index")
244 self.__row = df.iloc[0]
245 return self.__row
247 def _get(self, col: str):
248 val = self._row.get(col)
249 if val is None: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true
250 return None
251 try:
252 if pd.isna(val): 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true
253 return None
254 except (TypeError, ValueError):
255 pass
256 return val
258 # Product / archive identity
259 pds_dataset_name = IndexColumn("DATA_SET_ID", str)
260 pds_volume_name = IndexColumn("VOLUME_ID", str)
261 original_product_id = IndexColumn("ORIGINAL_PRODUCT_ID", str)
262 product_version = IndexColumn("PRODUCT_VERSION_ID", str)
263 instrument_host = IndexColumn("INSTRUMENT_HOST_ID", str)
264 instrument = IndexColumn("INSTRUMENT_ID", str)
265 mission_phase = IndexColumn("MISSION_PHASE_NAME", str)
266 rationale_desc = IndexColumn("RATIONALE_DESC", str)
267 data_quality = IndexColumn("DATA_QUALITY_ID", int)
269 # Timing
270 start_time = IndexColumn("START_TIME", _raw)
271 stop_time = IndexColumn("STOP_TIME", _raw)
272 nac_preroll_start_time = IndexColumn("NAC_PREROLL_START_TIME", _raw)
273 spacecraft_clock_partition = IndexColumn("SPACECRAFT_CLOCK_PARTITION", int)
274 spacecraft_clock_start_count = IndexColumn("SPACECRAFT_CLOCK_START_COUNT", str)
275 spacecraft_clock_stop_count = IndexColumn("SPACECRAFT_CLOCK_STOP_COUNT", str)
276 nac_spacecraft_clock_preroll_count = IndexColumn("NAC_SPACECRAFT_CLOCK_PREROLL_COUNT", str)
278 # Orbit / attitude
279 target_name = IndexColumn("TARGET_NAME", str)
280 orbit_number = IndexColumn("ORBIT_NUMBER", int)
281 slew_angle = IndexColumn("SLEW_ANGLE", float)
282 """Off-nadir slew angle (degrees). Equivalent to emission_angle."""
283 lro_node_crossing = IndexColumn("ORBIT_NODE", str)
284 lro_flight_direction = IndexColumn("LRO_FLIGHT_DIRECTION", str)
286 # NAC instrument / mode
287 nac_line_exposure_duration = IndexColumn("NAC_LINE_EXPOSURE_DURATION", float)
288 nac_frame = IndexColumn("NAC_FRAME_ID", str)
289 nac_dac_reset = IndexColumn("NAC_DAC_RESET", int)
290 nac_channel_a_offset = IndexColumn("NAC_CHANNEL_A_OFFSET", int)
291 nac_channel_b_offset = IndexColumn("NAC_CHANNEL_B_OFFSET", int)
292 instrument_mode_code = IndexColumn("INSTRUMENT_MODE_CODE", int)
293 compand_select_code = IndexColumn("COMPAND_SELECT_CODE", int)
294 mode_compression = IndexColumn("MODE_COMPRESSION", bool)
295 mode_test = IndexColumn("MODE_TEST", bool)
297 # NAC temperatures
298 temperature_scs = IndexColumn("NAC_TEMPERATURE_SCS", float)
299 temperature_fpa = IndexColumn("NAC_TEMPERATURE_FPA", float)
300 temperature_fpga = IndexColumn("NAC_TEMPERATURE_FPGA", float)
301 temperature_telescope = IndexColumn("NAC_TEMPERATURE_TELESCOPE", float)
303 # Image dimensions
304 lines = IndexColumn("IMAGE_LINES", int)
305 line_samples = IndexColumn("LINE_SAMPLES", int)
306 sample_bits = IndexColumn("SAMPLE_BITS", int)
307 scaled_pixel_width = IndexColumn("SCALED_PIXEL_WIDTH", float)
308 scaled_pixel_height = IndexColumn("SCALED_PIXEL_HEIGHT", float)
309 pixel_scale = IndexColumn("RESOLUTION", float)
310 """Ground-sample distance in metres/pixel."""
312 # Illumination / geometry angles
313 emission_angle = IndexColumn("EMISSION_ANGLE", float)
314 """Off-nadir emission angle (degrees). Same as slew_angle."""
315 incidence_angle = IndexColumn("INCIDENCE_ANGLE", float)
316 phase_angle = IndexColumn("PHASE_ANGLE", float)
317 north_azimuth = IndexColumn("NORTH_AZIMUTH", float)
318 sub_solar_azimuth = IndexColumn("SUB_SOLAR_AZIMUTH", float)
319 sub_solar_latitude = IndexColumn("SUB_SOLAR_LATITUDE", float)
320 sub_solar_longitude = IndexColumn("SUB_SOLAR_LONGITUDE", float)
321 sub_spacecraft_latitude = IndexColumn("SUB_SPACECRAFT_LATITUDE", float)
322 sub_spacecraft_longitude = IndexColumn("SUB_SPACECRAFT_LONGITUDE", float)
323 solar_distance = IndexColumn("SOLAR_DISTANCE", float)
324 """Distance from the Sun in km."""
325 solar_longitude = IndexColumn("SOLAR_LONGITUDE", float)
327 # Footprint coordinates
328 center_latitude = IndexColumn("CENTER_LATITUDE", float)
329 center_longitude = IndexColumn("CENTER_LONGITUDE", float)
330 upper_right_latitude = IndexColumn("UPPER_RIGHT_LATITUDE", float)
331 upper_right_longitude = IndexColumn("UPPER_RIGHT_LONGITUDE", float)
332 lower_right_latitude = IndexColumn("LOWER_RIGHT_LATITUDE", float)
333 lower_right_longitude = IndexColumn("LOWER_RIGHT_LONGITUDE", float)
334 lower_left_latitude = IndexColumn("LOWER_LEFT_LATITUDE", float)
335 lower_left_longitude = IndexColumn("LOWER_LEFT_LONGITUDE", float)
336 upper_left_latitude = IndexColumn("UPPER_LEFT_LATITUDE", float)
337 upper_left_longitude = IndexColumn("UPPER_LEFT_LONGITUDE", float)
339 # Spacecraft position
340 spacecraft_altitude = IndexColumn("SPACECRAFT_ALTITUDE", float)
341 """Spacecraft altitude above the lunar surface in km."""
342 target_center_distance = IndexColumn("TARGET_CENTER_DISTANCE", float)
343 """Distance from spacecraft to lunar centre in km."""
345 # Display
346 def metadata_items(self) -> list[tuple[str, str]]:
347 """Return ``(label, value)`` pairs for all available metadata fields.
349 Used by the CLI query command and the GUI — single source of truth
350 for both ordering and human-readable labels.
351 """
352 result = []
353 for col, label in METADATA_LABELS:
354 val = self._get(col)
355 if val is not None:
356 result.append((label, str(val)))
357 return result
359 # URLs
360 @property
361 def edr_url(self) -> str | None:
362 """PDS URL for the raw EDR .IMG file."""
363 edr = self.edr_id
364 if edr is None:
365 return None
366 if self.product_type == "edr":
367 return self._get("PDS_URL")
368 # CDR image — cross-lookup the EDR counterpart
369 try:
370 df = CumIndex(dataset="edr").lookup([edr])
371 return df.iloc[0]["PDS_URL"] if not df.empty else None
372 except FileNotFoundError:
373 return None
375 @property
376 def cdr_url(self) -> str | None:
377 """PDS URL for the calibrated CDR .IMG file (requires CDR database)."""
378 cdr = self.cdr_id
379 if cdr is None:
380 return None
381 if self.product_type == "cdr":
382 return self._get("PDS_URL")
383 # EDR image — cross-lookup the CDR counterpart
384 try:
385 df = CumIndex(dataset="cdr").lookup([cdr])
386 return df.iloc[0]["PDS_URL"] if not df.empty else None
387 except FileNotFoundError:
388 return None
390 # Single-image downloads
391 def download_edr(self, output_dir: Path, force: bool = False) -> Path:
392 """Download the raw EDR to ``<output_dir>/<edr_id>.IMG``."""
393 return self._download(self.edr_url, self.edr_id, output_dir, force)
395 def download_cdr(self, output_dir: Path, force: bool = False) -> Path:
396 """Download the calibrated CDR to ``<output_dir>/<cdr_id>.IMG`` (requires the CDR database)."""
397 return self._download(self.cdr_url, self.cdr_id, output_dir, force)
399 def _download(self, url: str | None, pid: str | None, output_dir: Path, force: bool) -> Path:
400 if not url or not pid:
401 raise ValueError(f"No download URL for {pid or self._product_id} (is its index built?)")
402 dest = Path(output_dir) / f"{pid}.IMG"
403 if reuse_existing(dest, force):
404 return dest
405 return download_file(url, dest)
407 # Processing
408 def process(self, output_dir: Path, **kwargs: Any) -> Path:
409 """Run the ISIS + GDAL pipeline on the EDR, returning the GeoTIFF.
411 *kwargs* are [`NacProcessor`][lunar_toolkit.lroc.nac.NacProcessor] options (``projection``,
412 ``resolution``, ``web``, ``keep``, ``force``).
413 """
414 url = self.edr_url
415 if not url:
416 raise ValueError(f"No EDR URL for {self._product_id}")
417 return NacProcessor(output_dir, **kwargs).process(url)
419 # Quick view
420 def to_png(self, output_dir: Path, input_path: Path) -> Path:
421 """Convert an image (e.g. a GeoTIFF from [`process`][lunar_toolkit.lroc.nac.NacImage.process]) to
422 ``<output_dir>/<id>.png``."""
423 return to_png(input_path, Path(output_dir) / f"{self._product_id}.png")
425 def show(self, input_path: Path) -> None:
426 """Display an image file of this product inline (Jupyter / matplotlib)."""
427 img = mpimg.imread(str(input_path))
428 fig, ax = plt.subplots(figsize=(10, 8))
429 ax.imshow(img, cmap="gray")
430 ax.set_title(self._product_id)
431 ax.axis("off")
432 plt.tight_layout()
433 plt.show()
435 # Helpers
436 def _get_cumindex(self) -> CumIndex:
437 if self._cumindex is None:
438 dataset = self.product_type or "edr"
439 self._cumindex = CumIndex(dataset=dataset)
440 return self._cumindex
442 def __repr__(self) -> str:
443 return f"NacImage({self._product_id!r})"
445 def __eq__(self, other: object) -> bool:
446 return isinstance(other, NacImage) and self._product_id == other._product_id
448 def __hash__(self) -> int:
449 return hash(self._product_id)
452# NacImageCollection
453class NacImageCollection:
454 """An ordered collection of NacImages with bulk download and processing.
456 Returned by ``CumIndex.get_images()``. Behaves like a list but also
457 exposes bulk operations that parallelise across images.
458 """
460 def __init__(self, images: list[NacImage]) -> None:
461 self._images = images
463 @classmethod
464 def from_sources(
465 cls,
466 sources: list[str],
467 data_dir: Path | None = None,
468 ) -> NacImageCollection:
469 """Build a collection from product IDs or local .IMG paths.
471 Raw HTTP(S) URLs are skipped — use ``PdsDownloader`` for those.
472 Automatically routes ``LE``/``RE`` → EDR index, ``LC``/``RC`` → CDR index.
473 IDs not found in the index are silently excluded.
474 """
475 product_ids: list[str] = []
476 for s in sources:
477 s = s.strip()
478 if s.startswith(("http://", "https://")):
479 continue
480 p = Path(s)
481 pid = p.stem.upper() if p.exists() else _normalize_nac_id(s)
482 product_ids.append(pid)
484 if not product_ids:
485 return cls([])
487 cdr_ids = [p for p in product_ids if (parse_nac_id(p) or ("", "", ""))[2] == "cdr"]
488 edr_ids = [p for p in product_ids if p not in cdr_ids] # EDR, WAC or ambiguous
490 rows: dict[str, pd.Series] = {}
491 for dataset, ids in [("edr", edr_ids), ("cdr", cdr_ids)]:
492 if not ids:
493 continue
494 try:
495 ci = CumIndex(dataset=dataset, data_dir=data_dir)
496 df = ci.lookup(ids)
497 for _, row in df.iterrows():
498 pid = str(row.get("PRODUCT_ID", "")).strip().upper()
499 rows[pid] = row
500 except FileNotFoundError:
501 pass # dataset not yet built
503 images = [NacImage(pid, _row=rows[pid]) for pid in product_ids if pid in rows]
504 return cls(images)
506 # List interface
507 def __len__(self) -> int:
508 return len(self._images)
510 def __iter__(self):
511 return iter(self._images)
513 def __getitem__(self, idx):
514 return self._images[idx]
516 def __repr__(self) -> str:
517 return f"NacImageCollection({len(self._images)} images)"
519 # Metadata
520 def to_dataframe(self) -> pd.DataFrame:
521 """Return a DataFrame with one row per image (all metadata columns)."""
522 rows = [img._row for img in self._images]
523 return pd.DataFrame(rows).reset_index(drop=True)
525 # Bulk downloads
526 def download_edrs(
527 self,
528 output_dir: Path,
529 workers: int = 4,
530 force: bool = False,
531 ) -> dict[str, Path | None]:
532 """Download all EDR .IMG files in parallel.
534 Returns a dict mapping product_id → local Path (or None on failure).
535 """
536 return self._bulk_download(NacImage.download_edr, output_dir, workers, force)
538 def download_cdrs(
539 self,
540 output_dir: Path,
541 workers: int = 4,
542 force: bool = False,
543 ) -> dict[str, Path | None]:
544 """Download all CDR .IMG files in parallel (requires CDR database)."""
545 return self._bulk_download(NacImage.download_cdr, output_dir, workers, force)
547 def _bulk_download(
548 self, download: Callable[..., Path], output_dir: Path, workers: int, force: bool
549 ) -> dict[str, Path | None]:
550 return _parallel(
551 {img.product_id: img for img in self._images},
552 lambda img: download(img, output_dir, force=force),
553 workers,
554 )
556 # Bulk processing
557 def process(self, output_dir: Path, workers: int = 4, **kwargs: Any) -> dict[str, Path | None]:
558 """Run the ISIS + GDAL pipeline on all EDRs in parallel.
560 *kwargs* are [`NacProcessor`][lunar_toolkit.lroc.nac.NacProcessor] options. Returns a dict mapping
561 product_id → GeoTIFF Path (or None on failure).
562 """
563 urls = {img.product_id: img.edr_url for img in self._images}
564 sources = [(pid, url) for pid, url in urls.items() if url]
565 results: dict[str, Path | None] = dict.fromkeys(urls)
566 geotiffs = NacProcessor(output_dir, **kwargs).process_batch([url for _, url in sources], workers)
567 results.update((pid, tif) for (pid, _), tif in zip(sources, geotiffs))
568 return results
570 # Bulk PNG conversion
571 def to_pngs(
572 self,
573 output_dir: Path,
574 input_paths: dict[str, Path],
575 workers: int = 4,
576 ) -> dict[str, Path | None]:
577 """Convert a set of TIFs to PNGs via gdal_translate in parallel.
579 Parameters
580 ----------
581 output_dir : Path
582 input_paths : dict[str, Path]
583 Mapping of product_id → source TIF path (e.g. from download_cdrs()).
584 workers : int
586 Returns
587 -------
588 dict mapping product_id → PNG Path (or None on failure).
589 """
590 output_dir = Path(output_dir)
591 return _parallel(
592 {pid: (src, output_dir / f"{pid}.png") for pid, src in input_paths.items()},
593 lambda paths: to_png(*paths),
594 workers,
595 )
598class NacProcessor:
599 """Download, calibrate and map-project LROC NAC EDRs into GeoTIFFs.
601 Each image ends up in *output_dir* as ``<id>.tif`` (map-projected I/F,
602 `GEOTIFF_OPTIONS`). Intermediates
603 (a downloaded ``<id>.IMG``, ``<id>.cal.echo.cub``, ``<id>.map.cub``) are
604 written beside it and removed after success unless *keep*; after a
605 failure they stay, so a re-run resumes. Steps whose output exists are
606 skipped unless *force*.
608 Parameters
609 ----------
610 output_dir : Path
611 Where downloads, cubes and GeoTIFFs are written.
612 web : bool
613 Use the USGS SPICE web service in ``spiceinit``.
614 projection : str
615 ISIS projection for ``mosrange`` (default: equirectangular).
616 resolution : float, optional
617 Map resolution in m/pixel (default: ``mosrange``'s estimate).
618 keep : bool
619 Keep the downloaded EDR and the intermediate cubes.
620 force : bool
621 Redo every step even when its output exists.
622 """
624 def __init__(
625 self,
626 output_dir: Path = Path("data"),
627 *,
628 web: bool = True,
629 projection: str = DEFAULT_PROJECTION,
630 resolution: float | None = None,
631 keep: bool = False,
632 force: bool = False,
633 ) -> None:
634 self.output_dir = Path(output_dir)
635 self.web = web
636 self.projection = projection
637 self.resolution = resolution
638 self.keep = keep
639 self.force = force
641 def process(self, source: str | Path) -> Path:
642 """Process one EDR (local ``.IMG`` path or HTTP(S) URL); returns the GeoTIFF."""
643 source = str(source)
644 is_url = source.startswith(("http://", "https://"))
645 pid = Path(url_filename(source) if is_url else source).name.split(".")[0].upper()
646 if not pid: 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true
647 raise ValueError(f"Cannot derive image ID from {source!r}")
648 geotiff = self.output_dir / f"{pid}.tif"
649 if reuse_existing(geotiff, self.force):
650 return geotiff
651 if not is_dry_run(): 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true
652 self.output_dir.mkdir(parents=True, exist_ok=True)
653 img = self.output_dir / f"{pid}.IMG" if is_url else Path(source)
654 if is_url and not reuse_existing(img, self.force): 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true
655 download_file(source, img)
656 echo = calibrate_lronac(img, self.output_dir, web=self.web, keep=self.keep, force=self.force)
657 map_cub = self.output_dir / f"{pid}.map.cub"
658 map_project(echo, map_cub, self.projection, self.resolution, keep=self.keep)
659 run("gdal_translate", [*GEOTIFF_OPTIONS, map_cub, geotiff])
660 if not self.keep and not is_dry_run(): 660 ↛ 661line 660 didn't jump to line 661 because the condition on line 660 was never true
661 for f in (map_cub, echo, img if is_url else None):
662 if f is not None:
663 f.unlink(missing_ok=True)
664 log.info("Pipeline complete → %s", geotiff)
665 return geotiff
667 def process_batch(self, sources: Sequence[str | Path], max_workers: int = 4) -> list[Path | None]:
668 """Process several images in parallel (None for failures)."""
669 results: list[Path | None] = [None] * len(sources)
670 with ThreadPoolExecutor(max_workers=max_workers) as pool:
671 futures = {pool.submit(self.process, src): i for i, src in enumerate(sources)}
672 for future in as_completed(futures):
673 idx = futures[future]
674 try:
675 results[idx] = future.result()
676 except Exception as exc:
677 log.error("Failed %s: %s", sources[idx], exc)
678 return results
681def to_png(src: Path, dst: Path) -> Path:
682 """Convert an image to an 8-bit, min/max-scaled PNG with ``gdal_translate``."""
683 Path(dst).parent.mkdir(parents=True, exist_ok=True)
684 run("gdal_translate", ["-of", "PNG", "-ot", "Byte", "-scale", src, dst], capture=True)
685 return Path(dst)
688def _parallel(items: dict[str, _T], func: Callable[[_T], Path], workers: int) -> dict[str, Path | None]:
689 """``{key: func(item)}`` run in a thread pool; failures are logged and map to None."""
691 def call(key: str, item: _T) -> tuple[str, Path | None]:
692 try:
693 return key, func(item)
694 except Exception as exc:
695 log.error("%s: %s", key, exc)
696 return key, None
698 results: dict[str, Path | None] = {}
699 with ThreadPoolExecutor(max_workers=workers) as pool:
700 for future in as_completed([pool.submit(call, k, v) for k, v in items.items()]):
701 key, path = future.result()
702 results[key] = path
703 return results