asp-plot 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {asp_plot-2.0.0 → asp_plot-2.1.0}/AGENTS.md +3 -1
  2. {asp_plot-2.0.0 → asp_plot-2.1.0}/ARCHITECTURE.md +17 -10
  3. {asp_plot-2.0.0 → asp_plot-2.1.0}/CHANGELOG.md +24 -0
  4. {asp_plot-2.0.0 → asp_plot-2.1.0}/PKG-INFO +1 -1
  5. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/cli/stereo_geom.py +15 -5
  6. asp_plot-2.1.0/asp_plot/sensors/__init__.py +298 -0
  7. asp_plot-2.1.0/asp_plot/sensors/aster.py +533 -0
  8. asp_plot-2.1.0/asp_plot/sensors/base.py +442 -0
  9. asp_plot-2.1.0/asp_plot/sensors/dimap.py +449 -0
  10. asp_plot-2.1.0/asp_plot/sensors/dimap_v1.py +610 -0
  11. asp_plot-2.1.0/asp_plot/sensors/rpc.py +653 -0
  12. asp_plot-2.1.0/asp_plot/sensors/worldview.py +566 -0
  13. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/stereo_geometry.py +254 -78
  14. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/stereopair_metadata_parser.py +23 -7
  15. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/utils.py +38 -12
  16. {asp_plot-2.0.0 → asp_plot-2.1.0}/conda-forge-recipe/meta.yaml +8 -3
  17. {asp_plot-2.0.0 → asp_plot-2.1.0}/pyproject.toml +1 -1
  18. asp_plot-2.0.0/asp_plot/sensors.py +0 -1166
  19. {asp_plot-2.0.0 → asp_plot-2.1.0}/.flake8 +0 -0
  20. {asp_plot-2.0.0 → asp_plot-2.1.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  21. {asp_plot-2.0.0 → asp_plot-2.1.0}/.github/ISSUE_TEMPLATE/problem-report.yml +0 -0
  22. {asp_plot-2.0.0 → asp_plot-2.1.0}/.github/workflows/claude-code-review.yml +0 -0
  23. {asp_plot-2.0.0 → asp_plot-2.1.0}/.github/workflows/claude.yml +0 -0
  24. {asp_plot-2.0.0 → asp_plot-2.1.0}/.github/workflows/release.yml +0 -0
  25. {asp_plot-2.0.0 → asp_plot-2.1.0}/.github/workflows/run-tests.yml +0 -0
  26. {asp_plot-2.0.0 → asp_plot-2.1.0}/.gitignore +0 -0
  27. {asp_plot-2.0.0 → asp_plot-2.1.0}/.pre-commit-config.yaml +0 -0
  28. {asp_plot-2.0.0 → asp_plot-2.1.0}/.readthedocs.yaml +0 -0
  29. {asp_plot-2.0.0 → asp_plot-2.1.0}/CLAUDE.md +0 -0
  30. {asp_plot-2.0.0 → asp_plot-2.1.0}/LICENSE +0 -0
  31. {asp_plot-2.0.0 → asp_plot-2.1.0}/README.md +0 -0
  32. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/__init__.py +0 -0
  33. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/alignment.py +0 -0
  34. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/altimetry.py +0 -0
  35. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/altimetry_plots.py +0 -0
  36. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/altimetry_source.py +0 -0
  37. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/asp_log.py +0 -0
  38. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/bodies.py +0 -0
  39. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/bundle_adjust.py +0 -0
  40. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/cli/__init__.py +0 -0
  41. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/cli/asp_report.py +0 -0
  42. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/cli/csm_camera_plot.py +0 -0
  43. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/cli/gallery.py +0 -0
  44. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
  45. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/csm_analysis.py +0 -0
  46. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/csm_camera.py +0 -0
  47. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/csm_io.py +0 -0
  48. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/gallery.py +0 -0
  49. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/icesat2_source.py +0 -0
  50. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/mapproject.py +0 -0
  51. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/planetary_source.py +0 -0
  52. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/processing_parameters.py +0 -0
  53. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/report.py +0 -0
  54. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/report_captions.py +0 -0
  55. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/report_pipeline.py +0 -0
  56. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/scenes.py +0 -0
  57. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/selections.py +0 -0
  58. {asp_plot-2.0.0 → asp_plot-2.1.0}/asp_plot/stereo.py +0 -0
  59. {asp_plot-2.0.0 → asp_plot-2.1.0}/environment.yml +0 -0
@@ -44,7 +44,7 @@ sphinx-autobuild docs docs/_build/html --open-browser # or sphinx-build for a
44
44
  - **Internet is required** for basemaps (contextily/Esri tiles), ICESat-2 requests (SlideRule), and ESA WorldCover sampling (public AWS S3 COGs). Tests must not depend on the network — basemap fetches are stubbed (#151).
45
45
  - **Mars altimetry needs the `*_pts_csv.csv`** (with `PLANET_RAD`), never the `*_topo_csv.csv`: MOLA TOPOGRAPHY is referenced to the oblate areoid while ASP DEMs use the spherical IAU datum — a latitude-dependent offset up to ~10 km that pc_align cannot remove. The loader rejects the topo file with an explanatory error.
46
46
  - **ASP's `mapproject` writes no log file**; its command is reconstructed from output GeoTIFF metadata (`mapproject.py`), not parsed from logs like the other tools.
47
- - **Attribution vs sensor naming is deliberate**: copyright/attribution names the rights-holder (`detect_satellite_attribution()` → `"Vantor"` or `"Airbus DS"`), while readers in `sensors.py` are named for the satellite family (WorldView, Pleiades). Don't reconcile them into one name (#137).
47
+ - **Attribution vs sensor naming is deliberate**: copyright/attribution names the rights-holder (`detect_satellite_attribution()` → `"Vantor"` or `"Airbus DS"`), while readers in the `asp_plot/sensors/` package are named for the satellite family (WorldView, Pleiades). Don't reconcile them into one name (#137).
48
48
  - **Package vs CLI naming is a deliberate split** (v2.0.0, #165): the *package* is `asp_plot` (`import asp_plot`, `pip install asp-plot`), the report *command* is `asp_report` (`asp_plot/cli/asp_report.py`). There is no `asp_plot` console script and no alias — don't "fix" either name to match the other. The other four CLIs (`stereo_geom`, `csm_camera_plot`, `request_planetary_altimetry`, `gallery`) are unprefixed and unchanged.
49
49
  - **Airbus DIMAP quaternions are scalar-first** (`Q0` = scalar); they are reordered to the scalar-last `q1..q4` layout the roll/pitch/yaw code expects in `PleiadesMetadata.getAtt_df()`. Don't "fix" the reorder.
50
50
  - **ASP multiview triangulation of mapprojected images needs `ISISROOT`** (observed with ASP 3.8.0-alpha, non-ISIS `-t pleiades` session): the joint triangulation aborts with an uncatchable `Isis::IException` (`$ISISROOT/IsisPreferences was not found`) that surfaces as a generic "Failed to run"/killed job. Workaround: `export ISISROOT=<ASP install root>` (the release bundles `IsisPreferences` there). Pair runs and raw-image multiview runs are unaffected; full write-up in a PR #155 comment.
@@ -67,6 +67,8 @@ Follow semantic versioning. To release:
67
67
 
68
68
  The rest is automated: `release.yml` detects the version bump, creates a GitHub Release + tag, and publishes to PyPI via OIDC trusted publishing; conda-forge's autotick bot then opens a feedstock PR. PyPI trusted publishing and the conda-forge feedstock are already configured (reference recipe in `conda-forge-recipe/meta.yaml`).
69
69
 
70
+ **The autotick bot only bumps `version` and `sha256` — it never syncs dependencies or entry points.** So whenever you add/remove a runtime dependency in `pyproject.toml` or add/rename a `[project.scripts]` entry point, the feedstock's `recipe/meta.yaml` must be edited by hand in the same release (`requirements: run:` and `build: entry_points:` + the matching `test: commands:`). Otherwise the conda build *succeeds* and then fails its own test phase, conda-build moves the package to `broken/`, and **nothing is uploaded** — PyPI advances while conda-forge silently stalls on the last good version. This is not hypothetical: adding `pyyaml` in v1.16.0 (#121) went unmirrored and stalled conda-forge at 1.15.1 for five releases (1.16.0 → 1.19.0), with a red ✗ on the feedstock's default branch the whole time. After releasing, check <https://anaconda.org/conda-forge/asp-plot> actually advanced rather than assuming the bot handled it.
71
+
70
72
  ## Common File Patterns
71
73
 
72
74
  ASP output files follow specific naming patterns (find them with the `glob_file()` utility):
@@ -31,7 +31,7 @@ The package is organized by functionality, with each module focused on a specifi
31
31
  - Coordinate utilities: `get_utm_epsg()` for determining UTM EPSG from lon/lat, `get_planetary_bounds()` for DEM bounds in planetocentric 0-360 lon/lat
32
32
  - Planetary body detection: `detect_planetary_body(dem_fn)` returns `"earth"`, `"moon"`, or `"mars"` by inspecting CRS WKT DATUM/ELLIPSOID fields
33
33
  - Subprocess utilities: `run_subprocess_command()`
34
- - Copyright/attribution utilities (an **attribution** concern — named for the rights-holder — kept deliberately distinct from sensor/reader **identity** in `sensors.py`; #137):
34
+ - Copyright/attribution utilities (an **attribution** concern — named for the rights-holder — kept deliberately distinct from sensor/reader **identity** in `sensors/`; #137):
35
35
  - `detect_satellite_attribution(directory)`: returns the rights-holder name or None — `"Vantor"` when an XML camera file's `SATID` matches `VANTOR_SATID_PREFIXES` (the DigitalGlobe→Maxar→Vantor heritage: WorldView `WV*` incl. Legion `WVLG`, GeoEye `GE*`, QuickBird `QB*`, IKONOS `IK*`), `"Airbus DS"` when an Airbus DIMAP document (root tag `Dimap_Document`; Pléiades/Pléiades Neo/SPOT) is present. Gates the copyright overlay via `Plotter.attribution`. `detect_vantor_satellite(directory)` remains as a bool wrapper for backward compatibility
36
36
  - `add_copyright_overlay(ax, attribution="Vantor")`: Adds "© {attribution} {year}" text overlay to bottom-right of matplotlib axes
37
37
  - Scene metadata: `get_acquisition_dates(directory, extra_dirs=None)` reads `FIRSTLINETIME` from WorldView/Maxar XMLs, falls back to the DIMAP refined-model start time (`Refined_Model/Time/Time_Range/START`, via `_dimap_acquisition_datetime()`) for Airbus products, and parses the capture timestamp from `AST_L1A_...` file/directory names. Returns a sorted, deduplicated list of `"YYYY-MM-DD HH:MM:SS UTC"` strings; empty if nothing is found. Used by the CLI to populate `ReportMetadata.acquisition_dates`.
@@ -81,11 +81,18 @@ The package is organized by functionality, with each module focused on a specifi
81
81
  - `reconstruct_mapproject_command(raster_path)`: returns the `mapproject ...` string, or `None` if the ASP mapproject tag signature (`REQUIRED_TAGS` = `INPUT_IMAGE_FILE` + `CAMERA_FILE` + `DEM_FILE`, all read back during reconstruction) is absent. Reuses the `utils.Raster` wrapper (free `NotGeoreferencedWarning` suppression + `get_epsg_code()` with the compound-CRS 2D fallback + `get_gsd()`/bounds) rather than re-opening with raw rasterio. `--t_srs` is `EPSG:XXXX` when an EPSG code exists (incl. the 2D fallback), else the quoted PROJ string (custom planetary/local frames, e.g. jitter stereographic); a malformed CRS returns `None` and logs instead of crashing the report. `_format_coord` renders coordinates to 12 significant figures, positionally — clean for large UTM northings *and* full-precision for degree-scale geographic `--tr`, with no scientific notation or float-repr noise. The reconstruction is faithful but **not byte-for-byte re-runnable** (session is the resolved `-t`, an input `--mpp` shows as the resolved `--tr`, output reads the actual filename) — the report flags this with a one-line note
82
82
  - `find_mapproject_commands(directories, stereo_command=None)`: scans dirs (processing root, BA dir, stereo dir) for all `*.tif`/`*.tiff` and keeps those carrying the tag signature — **identity is decided by the file's own metadata, never by filename**, so there is no naming-convention dependency (reading a GeoTIFF header is cheap; the `NotGeoreferencedWarning` from raw non-georef inputs is silenced). Dedupes by the reconstructed command string (identical scene reached via two dirs collapses; distinct left/right both show). When `stereo_command` is given, a discovered output is kept only if its filename appears in that command — this scopes the result to the run being reported, so a non-mapprojected run sharing a parent dir with mapprojected scenes (the `stereo/` + `stereo_no_mapproj/` layout) does **not** spuriously list a mapproject step. It's a whole-token basename membership test (the output basename must equal one of the stereo command's argument basenames — not a raw substring, so `run.tif` can't match `prun.tif`), not positional parsing. `ProcessingParameters.get_mapproject_commands(stereo_command)` passes the parsed stereo command; `report.py` renders the results under "Mapproject Command(s)" on the Processing Parameters page (via the module-level `_render_command_block` helper, shared with the bundle_adjust/stereo/point2dem commands)
83
83
 
84
- **`sensors.py`** - Sensor-specific scene metadata readers (issue #25)
85
- - `SensorMetadata` ABC defining the reader interface (`detect` + `detect_files` + `get_scene_dicts`) and the sensor-agnostic scene-dict schema, mirroring the `bodies.py` registry pattern so adding ASTER/HiRISE/etc. is a new subclass with no change to the geometry code
86
- - `WorldViewMetadata(SensorMetadata)`: the WorldView/Maxar XML logic (file discovery, `dg_mosaic` tiling, per-scene extraction, ephemeris/attitude/footprint) moved verbatim out of the parser. Named after the satellite family rather than the (twice-renamed: DigitalGlobe Maxar Vantor) company; the same format also covers GeoEye-1/QuickBird/IKONOS. Constructible from either a `directory=` (recursive discovery) or an explicit `image_list=` (already-resolved XML paths); non-camera files (`README.XML`, ortho) are filtered out either way
87
- - `PleiadesMetadata(SensorMetadata)`: Airbus Pléiades / Pléiades Neo DIMAP v2 reader. One scene per `DIM_*.XML` product (no tiling/mosaicking); `RPC_*.XML` sidecars share the DIMAP root tag but are filtered out by `METADATA_SUBPROFILE != PRODUCT` (cheap `iterparse` check). Mean view/sun angles and GSD are averaged over the nine `Located_Geometric_Values` blocks; `meansatel` = 90° mean incidence. Ephemeris `Point_List` carries per-point `TIME` values (no fixed interval like WorldView). Airbus quaternions are **scalar-first** (`Q0`) and are reordered to the scalar-last `q1..q4` layout the roll/pitch/yaw code expects. DIMAP has no ephemeris/attitude covariance and no scan direction/TDI: `cov_*` columns are NaN and `scandir`/`tdi` are None (consumers treat these as "not provided")
88
- - `SENSORS` registry + two detection entry points: `sensor_for_directory()` (scans a directory) and `sensor_for_inputs()` (accepts a mixed list of files/dirs/globs). Directory selection is **two-pass** every sensor gets a shallow (top-level) `detect()` first, then a recursive pass so a sensor whose metadata sits at the top level wins over one matching only inside a nested delivery, regardless of registry order; within a pass, order matters (`PleiadesMetadata` before the any-XML-matching `WorldViewMetadata`). `sensor_for_inputs()` routes through `resolve_xml_inputs()`, which expands directories (recursively), globs, and plain paths into a deduped, sorted XML list this is what lets `stereo_geom *.XML` / explicit files / a delivery dir all work without a fixed structure
84
+ **`sensors/`** - Sensor-specific scene metadata readers (issue #25; split from a single `sensors.py` into a package as groundwork for the sensor expansion in issue #168)
85
+ - Layout: `base.py` holds the `SensorMetadata` ABC and shared helpers (`_common_base`, `resolve_input_files` the shared `directory=`-or-`image_list=` constructor resolution every reader uses — the generic XML and image listers, the scene-dict defaults, and the shared ECEF/ENU geodesy helpers the two derived-geometry readers need); each sensor family gets its own module (`worldview.py`, `dimap.py` for DIMAP v2, `dimap_v1.py` for SPOT 5 / ALOS PRISM, `aster.py`, `rpc.py`); `__init__.py` holds the `SENSORS` registry, the detection entry points, and re-exports every public name, so `from asp_plot.sensors import ...` is unchanged from the pre-split module
86
+ - **Parsed vs derived readers** (issues #175, #177): most readers *parse* geometry a vendor wrote down; `aster.py` and `rpc.py` *derive* it, because their camera files record no summary geometry at all. All fill the same scene-dict schema, so nothing downstream distinguishes them the difference is only in where the numbers come from, and in which optional blocks end up "not provided"
87
+ - **Two attitude shapes, or none** (issues #179, #175): `att_df` is time-indexed with either scalar-last quaternions `q1..q4` (WorldView, DIMAP v2) or the vendor's own `roll`/`pitch`/`yaw` in degrees (DIMAP v1), plus NaN `cov_*` columns when the format has no covariance. Angle-reporting readers set `att_df.attrs["rpy_frame"]` because the frames are *not* interchangeable, and `StereoGeometryPlotter._orientation_series()` dispatches on which columns are present (quaternions compute against the orbital frame; angles plot as delivered, labeled with their frame). A sensor with no attitude *at all* (ASTER) sets `att_df = None`, which the orientation plot annotates rather than dispatching on consumers must handle all three. Likewise `eph_gdf` is time-indexed for every sensor that timestamps its trajectory, and line-indexed for ASTER and RPC-only products, which timestamp nothing. `eph_gdf` can also be **absent** from the scene dict entirely (issue #177) when no satellite position is recoverable at all; consumers use `d.get("eph_gdf")` and draw the footprint alone
88
+ - **Detection is content-based** (issue #162): each reader implements a single abstract `_is_camera_file()` content predicate (cheap `iterparse`, stops at the first identifying tags), and `base.py` builds discovery, filtering, `detect`, and `detect_files` on top of it once. A reader never claims another sensor's files just because they end in `.xml`; unrecognized inputs produce the clean "no supported sensor found" error. The WorldView check requires the `<isd>` root **and** the `IMD`/`EPH`/`ATT` blocks the root alone is not sufficient because ASP's `gen_aster` camera XMLs share it, and symmetrically the ASTER check requires the root plus the `LATTICE_POINT`/`SIGHT_VECTOR`/`SAT_POS` blocks. The DIMAP v2 check requires `METADATA_SUBPROFILE == PRODUCT` and a `METADATA_PROFILE` in `SUPPORTED_DIMAP_PROFILES`; products from other DIMAP profiles are skipped with a one-time warning naming the profile. DIMAP **v1** files (SPOT 5, ALOS PRISM) share the `Dimap_Document` root tag, so both families' checks key on the header container that differs — v1 `Metadata_Id` vs v2 `Metadata_Identification` which is what keeps either reader from claiming (or warning about) the other's files
89
+ - **Scene-dict schema = required core + optional blocks** (issue #163): the identity core (`xml_fn`, `catid`, `sensor`, `date`, `geom`) is read strictly; the summary fields (`OPTIONAL_SCENE_FIELDS` in `base.py`) degrade to NaN/None ("not provided") via `fill_scene_defaults()` when a tag is absent — `dg_mosaic` can strip image tags and Multi products carry per-band TDI, so the WorldView reader wraps those reads (`_tag_or_none`). Consumers render the defaults as omitted (`scandir`/`tdi`), "nan" (angle strings), or "N/A" (titles); the pair-level helpers (`get_pair_utm_epsg`, `get_intersection_bounds`) fall back to the footprint union for non-overlapping pairs, and `pair_dict` tolerates `date=None` (`cdate`/`dt` become None)
90
+ - `WorldViewMetadata(SensorMetadata)` (`worldview.py`): the WorldView/Maxar XML logic (file discovery, `dg_mosaic` tiling, per-scene extraction, ephemeris/attitude/footprint) moved verbatim out of the parser. Named after the satellite family rather than the (twice-renamed: DigitalGlobe → Maxar → Vantor) company; the same format also covers GeoEye-1/QuickBird/IKONOS. Constructible from either a `directory=` (recursive discovery) or an explicit `image_list=` (already-resolved XML paths); non-camera files (`README.XML`, ortho) are filtered out either way
91
+ - `PleiadesMetadata(SensorMetadata)` (`dimap.py`): Airbus-family DIMAP v2 reader — Pléiades 1A/1B (`PHR_SENSOR`), Pléiades Neo (`PNEO_SENSOR`), SPOT 6/7 (`S6_SENSOR`/`S7_SENSOR`), and PeruSat-1 (`PER1_SENSOR`), mirroring ASP's own coverage of the layout (`PleiadesXML.cc`, `PeruSatXML.cc`); the accepted profiles are the `SUPPORTED_DIMAP_PROFILES` tuple, and `SPEC_ONLY_DIMAP_PROFILES` (S6/S7/PER1) marks profiles implemented from the ASP reader spec but not yet validated against real deliveries — parsing one emits a one-time warning asking for reports (#168). One scene per `DIM_*.XML` product (no tiling/mosaicking); `RPC_*.XML` sidecars share the DIMAP root tag but are filtered out by `METADATA_SUBPROFILE != PRODUCT` (cheap `iterparse` check). Mean view/sun angles and GSD are averaged over the `Located_Geometric_Values` blocks (nine for Pléiades/SPOT, a single center block for PeruSat-1); `meansatel` = 90° − mean incidence. Ephemeris `Point_List` carries per-point `TIME` values (no fixed interval like WorldView). Attitude comes in two shapes: tabulated `Quaternion_List` samples (PNEO/SPOT 6/7/PeruSat) or per-component degree-3 `Polynomial_Quaternions` (Pléiades 1A/1B, #161), evaluated at the ephemeris timestamps — argument `(t − (midnight + OFFSET)) / SCALE`, ascending-power coefficients — then normalized, so both shapes yield the same tabulated `att_df`. Airbus quaternions are **scalar-first** (`Q0`) and are reordered to the scalar-last `q1..q4` layout the roll/pitch/yaw code expects. DIMAP has no ephemeris/attitude covariance and no scan direction/TDI: `cov_*` columns are NaN and `scandir`/`tdi` are None (consumers treat these as "not provided")
92
+ - `Spot5Metadata` / `PrismMetadata` (`dimap_v1.py`, issue #179): the DIMAP v1 family, sharing a `DimapV1Metadata` base for what the two formats have in common — the `Metadata_Id` header scan (`_scan_v1_header`, `iterparse` stopping at `Data_Strip`), the `Dataset_Frame` `Vertex` footprint (`FRAME_LON`/`FRAME_LAT`), the `Ephemeris/Points/Point` trajectory (`Location`/`Velocity` `X`/`Y`/`Z` children, unlike v2's whitespace-separated `LOCATION_XYZ`), and the roll/pitch/yaw `att_df` assembly. Mirrors ASP's `SPOT_XML.cc` (+ `LinescanSpotModel.cc`) and `PRISM_XML.cc` (+ `prism2asp.cc`). The differences are the identification gate and the attitude block: **SPOT 5** identifies on `MISSION`/`MISSION_INDEX` (ASP has no profile tag to check; only index 5 is an ASP stereo session, others are skipped with a warning) and reads `Corrected_Attitudes/Corrected_Attitude/Angles` **in radians**, converted to degrees here; **PRISM** gates on `METADATA_PROFILE == "ALOS"` (ASP's own check) and reads `Satellite_Attitudes/Angles_List/Angles/Angle` already **in degrees**. Angles are read by tag name, never positionally — SPOT lists them YAW/PITCH/ROLL, PRISM ROLL/PITCH/YAW. **Frames differ and are not reconciled**: PRISM's angles are relative to the same (along, across, down) orbital frame and `Rz(yaw)Ry(pitch)Rx(roll)` convention this package derives for quaternion sensors, so they are directly comparable; SPOT 5's are in the SPOT Geometry Handbook navigation frame (orbital axes X across-track, Y along-track, Z up, composed `Mp*Mr*My`), so they are reported as delivered and labeled, not silently mixed. DIMAP v1 reports no covariance, no scan/TDI, and **no satellite azimuth** — so `meansataz` is NaN, the pair convergence angle degrades to NaN, and the skyplot is annotated as unavailable rather than drawn empty. Both readers are written from ASP's spec with no real delivery to validate against, so parsing warns once per reader (`_warned_spec_only`), the docs name them as spec-only, and `tests/test_data/dimap_v1_synthetic/` holds synthetic fixtures (with the generator that wrote them)
93
+ - `AsterMetadata(SensorMetadata)` (`aster.py`, issue #175): the one **derived-geometry** reader. ASP's `gen_aster` camera XML has no timestamps, attitude, view/sun angles, or footprint corners — only `SAT_POS` (one ECEF position per lattice *line*; a pushbroom's position varies with line, not sample), `WORLD_SIGHT_VECTOR` (per-lattice-point ECEF look direction), `LATTICE_POINT` (the row-major (sample, line) grid those attach to), and `IMAGE_COLS`/`IMAGE_ROWS`. `_AsterLattice` intersects every look ray with the WGS84 ellipsoid (`_ray_ellipsoid_intersection`, near root; rays that miss *or point away from* the Earth raise rather than silently returning the far/backward root) to build a ground lattice, then a `RegularGridInterpolator` over it answers everything else: the footprint traces the **image** border through the interpolator, not the lattice extent — the lattice deliberately overshoots the image by a few hundred lines, and using it directly overstates the nadir band's area by ~14%. View angles are measured in two frames on purpose: azimuth/elevation at the *ground* point (the DG `MEANSATAZ`/`MEANSATEL` convention the pair math needs), off-nadir/in-track/cross-track at the *spacecraft* against geodetic nadir, with the along-track direction differenced from the `SAT_POS` endpoints (no velocities are recorded). Sign convention: in-track positive = looking forward, so band 3B's published 27.6° backward pointing must come out as −27.6 — that, ~15 m GSD, and a footprint inside the file's own RPC lat/lon box are the pinned validation targets, which is what makes this reader validated rather than spec-only despite deriving everything. Rays are intersected with the ellipsoid (h=0), not terrain, so the footprint shifts by `h·tan(off-nadir)`; view angles are unaffected (moving the ground point along its own ray does not change the ray). No attitude exists → `att_df = None`; no timestamps exist → `eph_gdf` is indexed by image line, and `date` comes from a neighbouring `AST_L1A_*` granule name (`utils.aster_datetime_from_name`, shared with `get_acquisition_dates`) or is None — both bands of a pair therefore report the same time even though 3B trails 3N by ~a minute. `catid` is the file stem (`SATID` is identical for both bands)
94
+ - `RpcMetadata(SensorMetadata)` (`rpc.py`, issue #177): the second **derived-geometry** reader, and the only one whose camera file is the *image*. Covers the whole `-t rpc` tail (Cartosat-1, Deimos, misc. commercial products) as one reader, because the camera model *is* the format. `read_rpc()` opens the raster header (GDAL exposes embedded RPCs and finds a `<stem>_RPC.TXT` sidecar itself), rejects anything with a CRS (an RPC describes the *raw* grid, so it is meaningless on an orthorectified product), and falls back to parsing a sidecar directly — which is what makes Cartosat-1's `<stem>_RPC_ORG.TXT` readable without the rename ASP does in `StereoSession.cc`; that one path warns once, since no real Cartosat delivery has exercised it. `_RpcGeometry` derives everything from one primitive: projecting a pixel at two heights (`HEIGHT_OFF ± HEIGHT_SCALE/2`) traces its look ray. The footprint is the image border projected at `HEIGHT_OFF`; azimuth/elevation are the centre pixel's ray in the local ENU frame at its ground point (the DG `MEANSATAZ`/`MEANSATEL` convention the pair math needs); the GSD is the ground spacing of one pixel at the centre. Intersecting the rays from samples 0 and `width` of one line recovers that line's **perspective centre** (`_closest_approach`, rejected when the rays miss by >1% of the range or the point falls below the ellipsoid), giving the off-nadir angle and a line-indexed position track. Validated against vendor truth rather than asserted: the committed WorldView camera XMLs' `<RPB>` blocks hold real RPC00B coefficients for scenes whose `MEANSATAZ`/`MEANSATEL`/`MEANOFFNADIRVIEWANGLE`/`MEANPRODUCTGSD` are recorded separately, so rewriting those coefficients into bare image containers gives an RPC-only product with known answers (matched to 0.01°/0.15°/0.15°/1 cm, footprint IoU >0.96, pair convergence to 0.02°) — which is what makes this reader validated rather than spec-only without a Cartosat sample. **Not** derived, on purpose: `meanintrackviewangle`/`meancrosstrackviewangle`, because splitting off-nadir needs a velocity direction and the recovered positions' km-level noise over the ~15 km of track one scene spans tilts it by 8–10° (measured against the vendor ephemeris) while the total off-nadir angle needs no velocity at all; and time, so `date` comes from the container (NITF `IDATIM`, TIFF `DateTime`) or is None. `att_df = None`, `catid` is the image stem, and `eph_gdf` is omitted entirely when no perspective centre is recoverable
95
+ - `SENSORS` registry + two detection entry points (`__init__.py`): `sensor_for_directory()` (scans a directory) and `sensor_for_inputs()` (accepts a mixed list of files/dirs/globs). Directory selection is **two-pass** — every sensor gets a shallow (top-level) `detect()` first, then a recursive pass — so a sensor whose metadata sits at the top level wins over one matching only inside a nested delivery, regardless of registry order; within a pass, order matters (the strictly-identifying readers — `PleiadesMetadata`, `Spot5Metadata`, `PrismMetadata`, `AsterMetadata` — before the any-DG-XML-matching `WorldViewMetadata`). Readers marked `fallback = True` (only `RpcMetadata`) sit outside that competition and are tried *after* both passes have failed, since every delivery contains images and a nested camera XML must still win. `sensor_for_inputs()` routes through `resolve_camera_inputs()`, which expands directories (recursively), globs, and plain paths into a deduped, sorted candidate list of XMLs **and** images — this is what lets `stereo_geom *.XML` / `stereo_geom fore.tif aft.tif` / explicit files / a delivery dir all work without a fixed structure. (`resolve_xml_inputs()` remains the XML-only half, and is still exported.) Mixing both kinds in one list is safe because every reader filters it through its own `_is_camera_file()`
89
96
 
90
97
  **`stereopair_metadata_parser.py`** - `StereopairMetadataParser` class
91
98
  - Now a **sensor-agnostic orchestrator** (issue #25): detects a reader via `sensor_for_directory()` (from `directory=`) or `sensor_for_inputs()` (from a mixed `inputs=` list of files/dirs/globs), delegates scene discovery/extraction to it, and keeps only the pair-level geometry
@@ -175,9 +182,9 @@ The package is organized by functionality, with each module focused on a specifi
175
182
  **`stereo_geometry.py`** - `StereoGeometryPlotter` class
176
183
  - **Composes** a `StereopairMetadataParser` via `self.parser` rather than subclassing it (issue #25); imports `estim_satellite_orientation` from `csm_io` directly
177
184
  - Visualizes stereo acquisition geometry from XML metadata
178
- - `camera_files_from_stereo_run()`: recovers the camera metadata files named in a stereo run's command (parsed from the newest `*log-stereo*.txt` via `AspLog`, so a rerun into the same directory wins) so the report's geometry section covers only the run's scenes, not every camera file in the directory; returns None (→ directory fallback) when fewer than two cameras resolve (CSM `.json` cameras, unresolvable paths)
179
- - `stereo_geom_plot()`: Creates skyplot (satellite viewing angles) and map view (footprints)
180
- - `satellite_position_orientation_plot()`: Creates a 3-row × N-column figure (one column per scene) showing position covariance, roll/pitch/yaw orientation, and attitude covariance. Sensors without covariance data (e.g. Pléiades DIMAP) get plain position markers and an annotated "not provided" panel; scene labels omit scan/TDI when the sensor has none
185
+ - `camera_files_from_stereo_run()`: recovers the camera metadata files named in a stereo run's command (parsed from the newest `*log-stereo*.txt` via `AspLog`, so a rerun into the same directory wins) so the report's geometry section covers only the run's scenes, not every camera file in the directory; returns None (→ directory fallback) when fewer than two cameras resolve (CSM `.json` cameras, unresolvable paths). A command naming no camera model of *any* kind is an RPC-only run whose cameras are its images, so the image tokens are used instead (issue #177) — gated on `_NON_XML_CAMERA_EXTENSIONS` so a CSM run still means "nothing to scope to"
186
+ - `stereo_geom_plot()`: Creates skyplot (satellite viewing angles) and map view (footprints). Sensors that report no satellite azimuth (DIMAP v1) would draw an empty skyplot, so `_annotate_missing_view_angles()` labels it "not provided" instead — same reasoning as the covariance panels
187
+ - `satellite_position_orientation_plot()`: Creates a 3-row × N-column figure (one column per scene) showing position covariance, roll/pitch/yaw orientation, and attitude covariance. Sensors without covariance data (e.g. Pléiades DIMAP) get plain position markers and an annotated "not provided" panel; scene labels omit scan/TDI when the sensor has none. The orientation row goes through `_orientation_series()`, which dispatches on the `att_df` shape — quaternions (`q1..q4`) are converted to roll/pitch/yaw against the orbital reference frame estimated from the ephemeris (`_compute_roll_pitch_yaw`, truncated to the shorter table), while sensors delivering angles directly are plotted as-is; the panel title names the frame either way
181
188
 
182
189
  **`scenes.py`** - `ScenePlotter` class (inherits from `Plotter`)
183
190
  - Plots individual input scenes (satellite/spacecraft images)
@@ -305,7 +312,7 @@ Docs dependencies are in `pyproject.toml` under `[project.optional-dependencies]
305
312
 
306
313
  **Inheritance for shared scaffolding, composition for collaborators**: Plotting classes inherit from the `Plotter` base for consistent matplotlib setup and helpers (`save`, `plot_missing`, `plot_array`); the altimetry sources inherit `AltimetrySource` for shared DEM-sampling/outlier/CSV helpers. But cross-concern wiring is *composed*, not inherited (the #122 rewrite moved several god-classes this way): `Altimetry` composes its sources + plotter, `StereoGeometryPlotter` composes a `StereopairMetadataParser`, `StereopairMetadataParser` composes a `SensorMetadata` reader, and `StereoPlotter`/`ScenePlotter` compose a `*Files` discovery object.
307
314
 
308
- **Registries over conditionals**: per-target facts live in one registry rather than scattered `if body == ...` / `if sensor == ...` branches — `BODIES` (`bodies.py`), `SENSORS` (`sensors.py`), `ASP_LOG_FORMATS` (`asp_log.py`), `REPORT_SECTIONS` (`report_pipeline.py`). Adding a 4th body / 3rd sensor / new ASP-log format / reordered report section is a new registry entry, not edits across many files.
315
+ **Registries over conditionals**: per-target facts live in one registry rather than scattered `if body == ...` / `if sensor == ...` branches — `BODIES` (`bodies.py`), `SENSORS` (`sensors/`), `ASP_LOG_FORMATS` (`asp_log.py`), `REPORT_SECTIONS` (`report_pipeline.py`). Adding a 4th body / 3rd sensor / new ASP-log format / reordered report section is a new registry entry, not edits across many files.
309
316
 
310
317
  **Lazy Loading**: Data is loaded on-demand (e.g., DEMs, hillshades) rather than in `__init__` to avoid unnecessary I/O.
311
318
 
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.1.0] - 2026-07-30
9
+
10
+ `asp_plot` now reads the same satellite camera metadata the Stereo Pipeline itself does ([#168](https://github.com/uw-cryo/asp_plot/issues/168)). Where 2.0.0 added Airbus Pléiades, this release finishes the job: the rest of the DIMAP v2 family (Pléiades 1A/1B attitude, SPOT 6/7, PeruSat-1), DIMAP v1 (SPOT 5, ALOS PRISM), ASTER, and RPC-only products (Cartosat-1, Deimos, anything ASP runs with `-t rpc`). The one gap left is ASP's `pinhole`/`opticalbar` sessions — historical aerial and declassified film, which carry no satellite geometry to plot.
11
+
12
+ Two of those readers *derive* their geometry rather than parsing it, because their camera files record none: ASTER writes only look vectors, and an RPC-only product is nothing but a camera model. Both are validated against published or vendor-reported geometry to a tenth of a degree.
13
+
14
+ Everything is additive — no API changes, and no new dependencies.
15
+
16
+ ### Added
17
+ - **RPC-only stereo-geometry support (Cartosat-1, Deimos, anything ASP runs with `-t rpc`), derived from the camera model itself** ([#177](https://github.com/uw-cryo/asp_plot/issues/177), [#168](https://github.com/uw-cryo/asp_plot/issues/168)). These products ship no camera file at all — just rational polynomial coefficients inside the image (or a `*_RPC.TXT` sidecar, including Cartosat-1's `*_RPC_ORG.TXT` variant, which GDAL does not pick up on its own and `sensors/rpc.py` therefore parses directly). An RPC is still a camera model, so the geometry is derived from it: projecting a pixel to the ground at two heights traces its look ray, which gives the footprint (the image border projected at `HEIGHT_OFF`), the satellite azimuth/elevation at the ground point, and the GSD; intersecting the look rays from opposite ends of one image line recovers the satellite's perspective centre, which gives the off-nadir angle and a line-indexed position track. RPC-only runs therefore get a full Stereo Geometry section in the report and a working `stereo_geom` — pointed at the images rather than at XMLs. The derivation is validated against vendor truth rather than asserted: rewriting the RPC00B coefficients from the committed WorldView camera XMLs into bare image containers reproduces those scenes' own `MEANSATAZ` to 0.01°, `MEANSATEL` and `MEANOFFNADIRVIEWANGLE` to 0.15°, `MEANPRODUCTGSD` to 1 cm, their footprints to an IoU above 0.96, and the pair's convergence angle to 0.02° (42.84° vs 42.82°). What RPCs genuinely do not record stays "not provided": `att_df` is None, sun angles and cloud cover are NaN, `date` comes from the image header (NITF `IDATIM`, TIFF `DateTime`) or is absent, and the in-track/cross-track split of the off-nadir angle is left NaN because the only velocity direction available — the drift of the recovered positions over the ~15 km of track one scene spans — is 8–10° off. Since every WorldView and Pléiades delivery also ships images carrying RPCs, this reader is registered as a `fallback`: it is consulted only after every XML-based reader has declined the input at every search depth.
18
+ - **ASTER stereo-geometry support, derived from look vectors** ([#175](https://github.com/uw-cryo/asp_plot/issues/175), [#168](https://github.com/uw-cryo/asp_plot/issues/168)). ASP's `gen_aster` camera XML records no timestamps, attitude, view/sun angles, or footprint corners — so `sensors/aster.py` is the first reader that *derives* its scene dict instead of parsing it. Intersecting each `WORLD_SIGHT_VECTOR` look ray (from its `SAT_POS`, one position per lattice line) with the WGS84 ellipsoid yields a ground lattice, from which the footprint (traced around the *image* border, not the lattice extent, which overshoots it by ~14% in area), the satellite azimuth/elevation at the ground point, the off-nadir/in-track/cross-track angles at the spacecraft, and the ground sample distance all follow. ASTER runs therefore get a full Stereo Geometry section in the report and a working `stereo_geom`, including the skyplot and convergence angle. The derivation is validated against ASTER's published geometry: the committed 3N/3B fixtures reproduce the 27.6° backward telescope pointing (as −27.6 in-track, positive being forward), ~15 m VNIR GSD, ~31° ground convergence and a 0.56 base-to-height ratio, with the footprint inside the camera file's own RPC bounding box. What ASTER genuinely does not record stays "not provided": `att_df` is None (the orientation and covariance panels now say so rather than raising), sun angles and cloud cover are NaN, `eph_gdf` is indexed by image line rather than time, and `date` is recovered from a neighbouring `AST_L1A_*` granule name when one is present — which means both bands of a pair report the same acquisition time even though the backward look trails the nadir one by roughly a minute. Both committed ASTER example reports are regenerated with the new section (their `--plot_geometry False` existed only because there was no reader), each gaining one page.
19
+ - **SPOT 5 and ALOS PRISM stereo-geometry support** ([#179](https://github.com/uw-cryo/asp_plot/issues/179), [#168](https://github.com/uw-cryo/asp_plot/issues/168)). A new `sensors/dimap_v1.py` adds the two DIMAP v1-family readers ASP supports — `Spot5Metadata` (mirroring `SPOT_XML.cc`, the `spot5` session) and `PrismMetadata` (mirroring `PRISM_XML.cc`, gated on `METADATA_PROFILE == "ALOS"` exactly as ASP is) — sharing a base for what the formats have in common: the `Metadata_Id` header, `Dataset_Frame` corner footprints, and `Ephemeris/Points/Point` trajectories. Neither format reports quaternions, so `att_df` grows a second shape: time-indexed `roll`/`pitch`/`yaw` in degrees (converted from radians for SPOT 5) alongside the existing scalar-last `q1..q4`, with `attrs["rpy_frame"]` naming the frame the angles are defined in. `StereoGeometryPlotter._orientation_series()` dispatches on which columns are present — quaternions are still converted to roll/pitch/yaw against the orbital frame, vendor angles are plotted as delivered — and the panel title names the frame either way, because the two are *not* interchangeable: PRISM's angles share this package's (along, across, down) orbital frame and `Rz Ry Rx` convention, while SPOT 5's are in the SPOT Geometry Handbook navigation frame. DIMAP v1 carries no satellite azimuth, so the convergence angle and skyplot markers degrade to NaN and the skyplot now says why instead of rendering empty. Both readers are written from ASP's reader spec with no real delivery available to validate against: parsing warns once per reader, the docs matrix marks them 🧪, and the synthetic fixtures in `tests/test_data/dimap_v1_synthetic/` ship with the generator that wrote them.
20
+ - **The DIMAP reader covers the full Airbus family ASP supports: Pléiades 1A/1B attitude, SPOT 6/7, and PeruSat-1** ([#161](https://github.com/uw-cryo/asp_plot/issues/161), [#168](https://github.com/uw-cryo/asp_plot/issues/168)). Pléiades 1A/1B products don't tabulate attitude samples — each quaternion component is a degree-3 polynomial in scaled time (`Polynomial_Quaternions`, argument `(t − (midnight + OFFSET)) / SCALE`), which previously made the reader fail on 1A/1B attitude. The reader now evaluates the polynomials at the ephemeris timestamps — mirroring ASP's own `read_attitudes_1A1B` (`PleiadesXML.cc`) and `get_camera_pose_at_time` (`LinescanPleiadesModel.cc`) — and normalizes the result, so 1A/1B scenes yield the same tabulated scalar-last `att_df` as every other sensor and the roll/pitch/yaw plots work unchanged. SPOT 6/7 (`S6_SENSOR`/`S7_SENSOR`) and PeruSat-1 (`PER1_SENSOR`, single `Located_Geometric_Values` block instead of nine) share the DIMAP v2 layout and are now accepted by the profile gate. Because these three profiles are implemented from the ASP reader spec rather than validated against real deliveries, parsing one emits a one-time warning asking for issue reports, and the docs support matrix records the distinction (🧪 vs ✅).
21
+
22
+ ### Changed
23
+ - **Scene dicts may now omit `eph_gdf` entirely, and the plots handle it** ([#177](https://github.com/uw-cryo/asp_plot/issues/177)). ASTER established `att_df = None` for sensors that record no attitude; RPC-only products go one step further, since a camera model whose look rays do not converge yields no satellite position at all. The pair map, the multi-view overview map and the position/orientation panel now read `eph_gdf` with `.get()` and fall back to drawing the footprint alone (with a legend proxy, because matplotlib's legend cannot use the polygon collection geopandas draws), instead of raising. `camera_files_from_stereo_run()` likewise learned to fall back to the *image* tokens of a stereo command that names no camera model of any kind — an RPC-only run — while a CSM run's `.json` cameras still mean "nothing to scope to".
24
+ - **Sensor detection is content-based** ([#162](https://github.com/uw-cryo/asp_plot/issues/162)). The WorldView reader previously claimed *any* XML that wasn't named `*ortho*`/`README` and then failed deep inside parsing (`ValueError: Tag 'SATID' not found ...`) when handed unrelated files. Each reader now implements a cheap `_is_camera_file()` content check (`iterparse`, stopping at the first identifying tags) that discovery, filtering, and detection are built on in `sensors/base.py` — deduplicating the shallow-then-recursive discovery pattern the two readers previously repeated. WorldView requires the `<isd>` root plus the `IMD`/`EPH`/`ATT` blocks (mirroring ASP's own `RPC_XML.cc` requirements; the root alone would still claim ASP's `gen_aster` ASTER XMLs, which share it), and `dg_mosaic` outputs still pass. The DIMAP reader additionally requires a supported `METADATA_PROFILE`: products from unsupported DIMAP profiles are skipped with a one-time warning naming the profile instead of a wrong parse. Unrecognized inputs now produce the clean "No supported sensor metadata files found" error, and a new sensor support matrix in the docs records what's validated, planned ([#168](https://github.com/uw-cryo/asp_plot/issues/168)), and out of scope.
25
+ - **WorldView scene dicts degrade gracefully when optional tags are missing** ([#163](https://github.com/uw-cryo/asp_plot/issues/163)). `dg_mosaic` can strip image tags and Multi (multispectral) products carry per-band TDI rather than a single `TDILEVEL`, but the reader previously crashed on any missing summary tag. The scene-dict schema is now formalized in `sensors/base.py` as a required identity core (`xml_fn`, `catid`, `sensor`, `date`, `geom` — still read strictly) plus optional fields (`OPTIONAL_SCENE_FIELDS`) that land as `None` (`scandir`, `tdi` — omitted from scene strings) or NaN (mean view/sun angles, GSD, cloud cover — rendered as "nan"), matching the "not provided" convention the Pléiades reader established. Pair-level consumers are hardened to match: `get_pair_utm_epsg()` and `get_intersection_bounds()` fall back to the footprint union for non-overlapping pairs (previously `AttributeError`/`TypeError` on `None`), and `pair_dict()`/`get_title()` tolerate scenes without timestamps (`cdate`/`dt` become None, rendered "N/A").
26
+ - **`asp_plot/sensors.py` is now the `asp_plot/sensors/` package** ([#168](https://github.com/uw-cryo/asp_plot/issues/168)). Pure reorganization as groundwork for broader sensor support: the `SensorMetadata` ABC and shared helpers move to `sensors/base.py`, the WorldView reader to `sensors/worldview.py`, the Airbus DIMAP reader to `sensors/dimap.py`, and the `SENSORS` registry plus the `sensor_for_directory()`/`sensor_for_inputs()`/`resolve_xml_inputs()` entry points to `sensors/__init__.py`, which re-exports every public name — `from asp_plot.sensors import ...` is unchanged, and no behavior changes. Also fixes the two WorldView scene-selection notebooks, which still called `parser.get_id_dict()`/`parser.xml2poly()` from before those methods moved from `StereopairMetadataParser` to the sensor readers (the correct call is `parser.reader.get_id_dict()`).
27
+
28
+ ### Fixed
29
+ - **"Please report this" warnings now point at a new issue rather than a closed one.** The spec-only reader warnings and the docs support matrix directed users to the issue that *implemented* each reader — but those close on merge, so SPOT 5 and ALOS PRISM reports were already being sent to closed [#179](https://github.com/uw-cryo/asp_plot/issues/179). All of them now link to the new-issue form.
30
+ - **`worldview_spacenet_ucsd_stereo.ipynb` runs again** ([#182](https://github.com/uw-cryo/asp_plot/issues/182)). It called `get_pair_utm_epsg()`, `get_scene_bounds()` and `get_intersection_bounds()` on `StereoGeometryPlotter`; all three live on `StereopairMetadataParser`, which the plotter has *composed* rather than inherited since [#25](https://github.com/uw-cryo/asp_plot/issues/25), so the notebook raised `AttributeError` on its first geometry cell. Its committed outputs predated the refactor, so nothing surfaced the break until a re-run.
31
+
8
32
  ## [2.0.0] - 2026-07-29
9
33
 
10
34
  A major version because two names changed. Neither has a back-compat alias, and both are one-line fixes at the call site:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asp_plot
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Package for plotting outputs Ames Stereo Pipeline processing
5
5
  Project-URL: Homepage, https://github.com/uw-cryo/asp_plot
6
6
  Project-URL: Documentation, https://asp-plot.readthedocs.io
@@ -11,7 +11,8 @@ from asp_plot.stereo_geometry import StereoGeometryPlotter
11
11
  "--directory",
12
12
  default=None,
13
13
  help=(
14
- "Directory containing XML files for stereo geometry analysis. "
14
+ "Directory containing camera metadata files (XMLs, or images carrying "
15
+ "RPCs) for stereo geometry analysis. "
15
16
  "Used when no positional INPUTS are given. Default: current directory."
16
17
  ),
17
18
  )
@@ -45,16 +46,25 @@ def main(
45
46
  output_filename,
46
47
  ):
47
48
  """
48
- Generate stereo geometry plots for WorldView XML files.
49
+ Generate stereo geometry plots from satellite camera metadata files.
50
+
51
+ The sensor is detected from the files themselves: WorldView (and other
52
+ DigitalGlobe-heritage) XML, Airbus DIMAP v2 (Pléiades 1A/1B and Neo,
53
+ SPOT 6/7, PeruSat-1), DIMAP v1 (SPOT 5, ALOS PRISM), ASTER
54
+ (ASP gen_aster camera XML), and RPC-only products such as Cartosat-1 and
55
+ Deimos, whose camera model lives in the image itself.
49
56
 
50
57
  This tool creates a skyplot and map visualization of the satellite positions
51
- and ground footprints. INPUTS may be any mix of XML files, directories, and
52
- glob patterns and need not follow a fixed directory structure, e.g.:
58
+ and ground footprints. INPUTS may be any mix of camera metadata files,
59
+ images carrying RPCs, directories, and glob patterns, and need not follow a
60
+ fixed directory structure, e.g.:
53
61
 
54
62
  stereo_geom *.XML
55
63
 
56
64
  stereo_geom scene1.xml scene2.xml
57
65
 
66
+ stereo_geom fore.tif aft.tif
67
+
58
68
  stereo_geom my_delivery_dir/
59
69
 
60
70
  If no INPUTS are given, --directory is used (default: current directory).
@@ -73,7 +83,7 @@ def main(
73
83
  )
74
84
  source_desc = base_directory
75
85
 
76
- print(f"\nProcessing stereo geometry for XML files in {source_desc}\n")
86
+ print(f"\nProcessing stereo geometry for camera metadata in {source_desc}\n")
77
87
 
78
88
  # Derive default output directory/filename from the base directory.
79
89
  dir_name = os.path.split(base_directory.rstrip("/\\"))[-1]
@@ -0,0 +1,298 @@
1
+ """Sensor-specific metadata readers for stereo scenes.
2
+
3
+ This package isolates the *sensor-specific* work of discovering scene files and
4
+ extracting per-scene metadata from the *sensor-agnostic* stereo-pair geometry
5
+ math in :mod:`asp_plot.stereopair_metadata_parser`.
6
+
7
+ The goal is flexibility: WorldView (and other DigitalGlobe-heritage) XML camera
8
+ files, the Airbus DIMAP v2 family (Pléiades 1A/1B and Neo, SPOT 6/7, PeruSat-1),
9
+ the DIMAP v1 family (SPOT 5, ALOS PRISM), ASTER ``gen_aster`` camera files, and
10
+ RPC-only products (Cartosat-1, Deimos, anything ASP runs with ``-t rpc``) are
11
+ supported, and adding a new sensor is a matter of writing a new
12
+ :class:`SensorMetadata` subclass in its own module and registering it in
13
+ ``SENSORS`` — no changes to the pair-level geometry code are required.
14
+
15
+ Most readers *parse* metadata a vendor wrote down; :mod:`asp_plot.sensors.aster`
16
+ and :mod:`asp_plot.sensors.rpc` instead *derive* their scene dicts (footprint,
17
+ view angles, GSD, satellite positions) from look vectors, because their camera
18
+ files record no summary geometry at all. Both kinds fill the same schema.
19
+
20
+ Most readers also claim *XML* files; the RPC reader claims images, because
21
+ that is where an RPC-only product keeps its camera model. It is therefore
22
+ marked ``fallback`` and consulted only after every XML reader has declined,
23
+ since a WorldView or Pléiades delivery ships images alongside its camera XMLs.
24
+
25
+ Each reader is responsible for turning a directory of camera/metadata files into
26
+ a list of *scene dicts*, one per scene, each containing the sensor-agnostic keys
27
+ the geometry code consumes:
28
+
29
+ ``xml_fn``, ``catid``, ``sensor``, ``date``, ``scandir``, ``tdi``, ``geom``
30
+ (a Shapely polygon footprint in EPSG:4326), the mean view-angle/GSD/sun
31
+ attributes (``meansataz``, ``meansatel``, ``meanoffnadirviewangle``,
32
+ ``meanintrackviewangle``, ``meancrosstrackviewangle``, ``meanproductgsd``,
33
+ ``meansunaz``, ``meansunel``, ``cloudcover``), and — when ``geteph`` is True —
34
+ ``eph_gdf`` (ephemeris GeoDataFrame in EPSG:4978), ``att_df`` (attitude
35
+ DataFrame), and ``fp_gdf`` (footprint GeoDataFrame in EPSG:4326).
36
+
37
+ ``eph_gdf`` and ``fp_gdf`` are the trajectory block's own optional members:
38
+ ``fp_gdf`` is always provided, while ``eph_gdf`` is **omitted entirely** by a
39
+ reader that can recover no satellite positions at all (an RPC-only product
40
+ whose look rays do not converge). Consumers use ``d.get("eph_gdf")`` and plot
41
+ the footprint alone when it is absent.
42
+
43
+ ``att_df`` comes in one of two shapes, depending on what the sensor reports:
44
+ quaternions (``q1..q4``, scalar-last) or the vendor's own roll/pitch/yaw
45
+ (``roll``/``pitch``/``yaw`` in degrees, with ``attrs["rpy_frame"]`` naming the
46
+ frame they are defined in). Both carry NaN-filled ``cov_*`` columns when the
47
+ format has no covariance. Consumers dispatch on which columns are present, and
48
+ must tolerate ``att_df`` being None: a sensor whose camera files record no
49
+ attitude at all (ASTER) reports it that way rather than inventing one.
50
+
51
+ ``eph_gdf`` is time-indexed for every sensor that timestamps its trajectory;
52
+ ASTER and RPC-only products, which timestamp nothing, index it by image line
53
+ number instead.
54
+
55
+ Layout: :mod:`asp_plot.sensors.base` holds the :class:`SensorMetadata` ABC and
56
+ shared helpers; each sensor family lives in its own module
57
+ (:mod:`asp_plot.sensors.worldview`, :mod:`asp_plot.sensors.dimap`,
58
+ :mod:`asp_plot.sensors.dimap_v1`, :mod:`asp_plot.sensors.aster`,
59
+ :mod:`asp_plot.sensors.rpc`); this
60
+ ``__init__`` holds the ``SENSORS`` registry and the detection entry points, and
61
+ re-exports every public name so ``from asp_plot.sensors import ...`` is stable
62
+ across the package split.
63
+ """
64
+
65
+ import glob
66
+ import logging
67
+ import os
68
+
69
+ from asp_plot.sensors.aster import AsterMetadata
70
+ from asp_plot.sensors.base import (
71
+ SensorMetadata,
72
+ _common_base,
73
+ list_candidate_images,
74
+ list_candidate_xmls,
75
+ )
76
+ from asp_plot.sensors.dimap import PleiadesMetadata
77
+ from asp_plot.sensors.dimap_v1 import PrismMetadata, Spot5Metadata
78
+ from asp_plot.sensors.rpc import RpcMetadata
79
+ from asp_plot.sensors.worldview import WorldViewMetadata
80
+
81
+ logging.basicConfig(level=logging.WARNING)
82
+ logger = logging.getLogger(__name__)
83
+
84
+ __all__ = [
85
+ "SENSORS",
86
+ "SensorMetadata",
87
+ "WorldViewMetadata",
88
+ "PleiadesMetadata",
89
+ "Spot5Metadata",
90
+ "PrismMetadata",
91
+ "AsterMetadata",
92
+ "RpcMetadata",
93
+ "resolve_camera_inputs",
94
+ "resolve_xml_inputs",
95
+ "sensor_for_directory",
96
+ "sensor_for_inputs",
97
+ ]
98
+
99
+ # Registry of available sensor readers, in detection-priority order. The DIMAP
100
+ # and ASTER readers identify strictly (root tag plus profile/mission tags, or
101
+ # the gen_aster lattice blocks) while the WorldView reader claims any XML
102
+ # carrying the DG camera blocks, so WorldView is checked last of the XML
103
+ # readers. RpcMetadata is a ``fallback`` reader (it claims *images*, which
104
+ # every delivery has) and is consulted only after all of these decline.
105
+ SENSORS = [
106
+ PleiadesMetadata,
107
+ Spot5Metadata,
108
+ PrismMetadata,
109
+ AsterMetadata,
110
+ WorldViewMetadata,
111
+ RpcMetadata,
112
+ ]
113
+
114
+
115
+ def resolve_xml_inputs(inputs, recursive=True):
116
+ """Expand files, directories, and glob patterns into XML file paths.
117
+
118
+ Lets a user point the tools at messy inputs without a fixed directory
119
+ structure — e.g. ``geom_plot *.XML`` (already expanded by the shell),
120
+ ``geom_plot scene1.xml scene2.xml``, ``geom_plot delivery_dir/``, or a mix.
121
+
122
+ Each item of ``inputs`` may be:
123
+
124
+ - a path to an XML file (included directly),
125
+ - a directory (searched with the sensor-neutral
126
+ :func:`asp_plot.sensors.base.list_candidate_xmls`, which is
127
+ shallow-first and falls back to a recursive search), or
128
+ - a glob pattern (expanded with :func:`glob.glob`).
129
+
130
+ Results are de-duplicated (by absolute path) and returned sorted. Directory
131
+ inputs get only the generic basename filter (``README.XML``, ortho
132
+ products); the sensor-specific *content* checks are applied by the
133
+ readers, not here.
134
+
135
+ Parameters
136
+ ----------
137
+ inputs : str or os.PathLike or iterable of those
138
+ One or more files, directories, and/or glob patterns.
139
+ recursive : bool, optional
140
+ Passed through to directory discovery and ``**`` glob expansion.
141
+ Default True.
142
+
143
+ Returns
144
+ -------
145
+ list of str
146
+ Sorted, de-duplicated XML file paths.
147
+ """
148
+ if isinstance(inputs, (str, os.PathLike)):
149
+ inputs = [inputs]
150
+
151
+ collected = []
152
+ for item in inputs:
153
+ item = os.path.expanduser(str(item))
154
+ if os.path.isdir(item):
155
+ collected.extend(list_candidate_xmls(item, recursive=recursive))
156
+ elif glob.has_magic(item):
157
+ collected.extend(glob.glob(item, recursive=recursive))
158
+ elif os.path.isfile(item):
159
+ collected.append(item)
160
+ else:
161
+ logger.warning("Input does not exist, skipping: %s", item)
162
+
163
+ seen = set()
164
+ unique = []
165
+ for path in collected:
166
+ key = os.path.abspath(path)
167
+ if key not in seen:
168
+ seen.add(key)
169
+ unique.append(path)
170
+ return sorted(unique)
171
+
172
+
173
+ def resolve_camera_inputs(inputs, recursive=True):
174
+ """Expand inputs into every candidate camera file: XMLs *and* images.
175
+
176
+ :func:`resolve_xml_inputs` covers the readers whose camera model is a
177
+ sidecar XML; RPC-only products carry theirs inside the image (issue #177),
178
+ so directory inputs also contribute candidate rasters here. Explicitly
179
+ named files are included whatever their extension, so
180
+ ``stereo_geom fore.tif aft.tif`` works.
181
+
182
+ Mixing the two kinds in one list is safe: every reader filters the list
183
+ through its own :meth:`SensorMetadata._is_camera_file` content check, so a
184
+ WorldView delivery's images are ignored by the WorldView reader and its
185
+ XMLs by the RPC reader.
186
+
187
+ Parameters
188
+ ----------
189
+ inputs : str or os.PathLike or iterable of those
190
+ One or more files, directories, and/or glob patterns.
191
+ recursive : bool, optional
192
+ Passed through to directory discovery and ``**`` glob expansion.
193
+ Default True.
194
+
195
+ Returns
196
+ -------
197
+ list of str
198
+ Sorted, de-duplicated candidate camera file paths.
199
+ """
200
+ if isinstance(inputs, (str, os.PathLike)):
201
+ inputs = [inputs]
202
+ inputs = list(inputs)
203
+
204
+ collected = list(resolve_xml_inputs(inputs, recursive=recursive))
205
+ seen = {os.path.abspath(p) for p in collected}
206
+ for item in inputs:
207
+ item = os.path.expanduser(str(item))
208
+ if not os.path.isdir(item):
209
+ continue
210
+ for path in list_candidate_images(item, recursive=recursive):
211
+ key = os.path.abspath(path)
212
+ if key not in seen:
213
+ seen.add(key)
214
+ collected.append(path)
215
+ return sorted(collected)
216
+
217
+
218
+ def sensor_for_inputs(inputs):
219
+ """Detect and instantiate the appropriate sensor reader for explicit inputs.
220
+
221
+ The file-list counterpart of :func:`sensor_for_directory`. Resolves
222
+ ``inputs`` (files, directories, and/or globs) into a list of candidate
223
+ camera files, then returns an instance of the first registered reader whose
224
+ :meth:`SensorMetadata.detect_files` matches — ``fallback`` readers last, so
225
+ a delivery's images cannot outrank its own camera XMLs.
226
+
227
+ Parameters
228
+ ----------
229
+ inputs : str or os.PathLike or iterable of those
230
+ One or more files, directories, and/or glob patterns.
231
+
232
+ Returns
233
+ -------
234
+ SensorMetadata
235
+ An initialized reader for the detected sensor.
236
+
237
+ Raises
238
+ ------
239
+ ValueError
240
+ If no candidate files are found, or no registered sensor reader
241
+ matches them.
242
+ """
243
+ image_list = resolve_camera_inputs(inputs)
244
+ if not image_list:
245
+ raise ValueError(
246
+ "\n\nNo camera metadata files found for the given input(s). "
247
+ "Provide camera XML files, images carrying RPCs, a directory, or "
248
+ "a glob pattern.\n\n"
249
+ )
250
+ base = _common_base(image_list)
251
+ for sensor_cls in sorted(SENSORS, key=lambda s: s.fallback):
252
+ if sensor_cls.detect_files(image_list):
253
+ return sensor_cls(directory=base, image_list=image_list)
254
+ raise ValueError(
255
+ "\n\nNo supported sensor metadata files found among the given input(s). "
256
+ f"Supported sensors: {', '.join(s.name for s in SENSORS)}.\n\n"
257
+ )
258
+
259
+
260
+ def sensor_for_directory(directory):
261
+ """Detect and instantiate the appropriate sensor reader for a directory.
262
+
263
+ Iterates the ``SENSORS`` registry and returns an instance of the first
264
+ reader whose :meth:`SensorMetadata.detect` matches the directory contents.
265
+
266
+ Parameters
267
+ ----------
268
+ directory : str
269
+ Path to directory containing camera/metadata files.
270
+
271
+ Returns
272
+ -------
273
+ SensorMetadata
274
+ An initialized reader for the detected sensor.
275
+
276
+ Raises
277
+ ------
278
+ ValueError
279
+ If no registered sensor reader matches the directory contents.
280
+ """
281
+ directory = os.path.expanduser(directory)
282
+ # Two passes, mirroring the readers' shallow-first discovery: a sensor
283
+ # whose metadata sits at the directory's top level wins over one that only
284
+ # matches somewhere inside a nested delivery, regardless of registry order.
285
+ # ``fallback`` readers sit outside that competition entirely and are tried
286
+ # only after both passes have failed: a delivery whose camera XMLs are
287
+ # nested must still be read by its own reader, not claimed by the RPC
288
+ # reader just because the delivery's images happen to sit higher up.
289
+ for fallback in (False, True):
290
+ candidates = [s for s in SENSORS if s.fallback is fallback]
291
+ for recursive in (False, True):
292
+ for sensor_cls in candidates:
293
+ if sensor_cls.detect(directory, recursive=recursive):
294
+ return sensor_cls(directory)
295
+ raise ValueError(
296
+ "\n\nNo supported sensor metadata files found in directory. "
297
+ f"Supported sensors: {', '.join(s.name for s in SENSORS)}.\n\n"
298
+ )