asp-plot 2.0.0__tar.gz → 2.2.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.2.0}/.github/workflows/claude-code-review.yml +1 -1
  2. {asp_plot-2.0.0 → asp_plot-2.2.0}/AGENTS.md +5 -1
  3. {asp_plot-2.0.0 → asp_plot-2.2.0}/ARCHITECTURE.md +25 -13
  4. {asp_plot-2.0.0 → asp_plot-2.2.0}/CHANGELOG.md +48 -0
  5. {asp_plot-2.0.0 → asp_plot-2.2.0}/PKG-INFO +2 -2
  6. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/cli/stereo_geom.py +15 -5
  7. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/csm_analysis.py +146 -27
  8. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/csm_camera.py +103 -26
  9. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/mapproject.py +38 -6
  10. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/report.py +2 -1
  11. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/report_captions.py +1 -1
  12. asp_plot-2.2.0/asp_plot/sensors/__init__.py +298 -0
  13. asp_plot-2.2.0/asp_plot/sensors/aster.py +533 -0
  14. asp_plot-2.2.0/asp_plot/sensors/base.py +442 -0
  15. asp_plot-2.2.0/asp_plot/sensors/dimap.py +449 -0
  16. asp_plot-2.2.0/asp_plot/sensors/dimap_v1.py +610 -0
  17. asp_plot-2.2.0/asp_plot/sensors/rpc.py +653 -0
  18. asp_plot-2.2.0/asp_plot/sensors/worldview.py +566 -0
  19. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/stereo.py +266 -73
  20. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/stereo_geometry.py +254 -78
  21. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/stereopair_metadata_parser.py +23 -7
  22. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/utils.py +38 -12
  23. {asp_plot-2.0.0 → asp_plot-2.2.0}/conda-forge-recipe/meta.yaml +8 -3
  24. {asp_plot-2.0.0 → asp_plot-2.2.0}/pyproject.toml +1 -1
  25. asp_plot-2.0.0/asp_plot/sensors.py +0 -1166
  26. {asp_plot-2.0.0 → asp_plot-2.2.0}/.flake8 +0 -0
  27. {asp_plot-2.0.0 → asp_plot-2.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  28. {asp_plot-2.0.0 → asp_plot-2.2.0}/.github/ISSUE_TEMPLATE/problem-report.yml +0 -0
  29. {asp_plot-2.0.0 → asp_plot-2.2.0}/.github/workflows/claude.yml +0 -0
  30. {asp_plot-2.0.0 → asp_plot-2.2.0}/.github/workflows/release.yml +0 -0
  31. {asp_plot-2.0.0 → asp_plot-2.2.0}/.github/workflows/run-tests.yml +0 -0
  32. {asp_plot-2.0.0 → asp_plot-2.2.0}/.gitignore +0 -0
  33. {asp_plot-2.0.0 → asp_plot-2.2.0}/.pre-commit-config.yaml +0 -0
  34. {asp_plot-2.0.0 → asp_plot-2.2.0}/.readthedocs.yaml +0 -0
  35. {asp_plot-2.0.0 → asp_plot-2.2.0}/CLAUDE.md +0 -0
  36. {asp_plot-2.0.0 → asp_plot-2.2.0}/LICENSE +0 -0
  37. {asp_plot-2.0.0 → asp_plot-2.2.0}/README.md +0 -0
  38. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/__init__.py +0 -0
  39. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/alignment.py +0 -0
  40. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/altimetry.py +0 -0
  41. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/altimetry_plots.py +0 -0
  42. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/altimetry_source.py +0 -0
  43. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/asp_log.py +0 -0
  44. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/bodies.py +0 -0
  45. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/bundle_adjust.py +0 -0
  46. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/cli/__init__.py +0 -0
  47. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/cli/asp_report.py +0 -0
  48. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/cli/csm_camera_plot.py +0 -0
  49. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/cli/gallery.py +0 -0
  50. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
  51. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/csm_io.py +0 -0
  52. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/gallery.py +0 -0
  53. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/icesat2_source.py +0 -0
  54. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/planetary_source.py +0 -0
  55. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/processing_parameters.py +0 -0
  56. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/report_pipeline.py +0 -0
  57. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/scenes.py +0 -0
  58. {asp_plot-2.0.0 → asp_plot-2.2.0}/asp_plot/selections.py +0 -0
  59. {asp_plot-2.0.0 → asp_plot-2.2.0}/environment.yml +0 -0
@@ -34,6 +34,6 @@ jobs:
34
34
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
35
35
  plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
36
36
  plugins: 'code-review@claude-code-plugins'
37
- prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
37
+ prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }} --comment'
38
38
  # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
39
39
  # or https://code.claude.com/docs/en/cli-reference for available options
@@ -44,8 +44,10 @@ 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
+ - **There is no "unadjusted" CSM state on disk after a `bundle_adjust` run** — it writes only `*.adjusted_state.json` for the *optimized* cameras, while the originals exist solely as vendor XML. To get the before-camera for `csm_camera_summary_plot()`, re-run with an identity transform: write a 4x4 identity to `identity.txt` and pass `--initial-transform identity.txt --apply-initial-transform-only` with the same images/XMLs into a separate `-o` prefix. That skips iterations, matching, and reports (seconds, not minutes), and the resulting `.adjust` files are exactly zero translation + identity quaternion, so you can verify the cameras really are untouched. Demonstrated in `notebooks/WorldView/worldview_spacenet_ucsd_stereo.ipynb`.
50
+ - **CSM camera *differences* deliberately diverge from ASP's `orbit_plot.py`** (#53): `csm_analysis.read_angles_common_frame()` puts both cameras in one satellite body frame estimated from the *original* ephemeris, instead of estimating a frame per camera from its own positions the way `csm_io.read_angles()` (the ASP mirror) does. A solver perturbs positions *and* resamples the ephemeris finer, and over the resulting ~140 m central-difference baseline a 2 m perturbation tilts a per-camera frame by ~0.8° — which is what made the example figure look full of outliers. Angle diffs are also wrapped to [-180, 180). Don't "re-sync" either back to `read_angles()`; `csm_io.py` stays a verbatim ASP mirror and is not where this belongs.
49
51
  - **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
52
  - **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.
51
53
 
@@ -67,6 +69,8 @@ Follow semantic versioning. To release:
67
69
 
68
70
  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
71
 
72
+ **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.
73
+
70
74
  ## Common File Patterns
71
75
 
72
76
  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`.
@@ -77,15 +77,22 @@ The package is organized by functionality, with each module focused on a specifi
77
77
  - Used by report generation to document processing settings
78
78
 
79
79
  **`mapproject.py`** - Reconstruct `mapproject` commands from output GeoTIFF metadata (issue #96)
80
- - ASP's `mapproject` writes **no log file** (unlike `bundle_adjust`/`stereo`/`point2dem`), so `asp_log.py` has nothing to parse for the mapprojection step. Instead of requiring a new ASP `--log` flag, the command is reconstructed **from the output data alone**: ASP stamps `INPUT_IMAGE_FILE` / `CAMERA_FILE` / `DEM_FILE` / `CAMERA_MODEL_TYPE` / `BUNDLE_ADJUST_PREFIX` into each mapprojected GeoTIFF header, and the raster's own CRS / resolution / bounds give `--t_srs` / `--tr` / `--t_projwin`
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
80
+ - ASP's `mapproject` writes **no log file** (unlike `bundle_adjust`/`stereo`/`point2dem`), so `asp_log.py` has nothing to parse for the mapprojection step. Instead of requiring a new ASP `--log` flag, the command is reconstructed **from the output data alone**: ASP stamps `INPUT_IMAGE_FILE` / `CAMERA_FILE` / `DEM_FILE` / `CAMERA_MODEL_TYPE` / `BUNDLE_ADJUST_PREFIX` into each mapprojected GeoTIFF header, and the raster's own CRS / resolution / bounds give `--t_srs` / `--tr` / `--t_projwin` (the bounds shifted half a pixel NW to ASP's pixel-edge convention, #148)
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. It **is grid-exact on ASP >= 3.7.0** (#148): `--t_projwin` is emitted as ASP's pixel-edge box — the GDAL bounds shifted half a pixel NW (`x − tr/2`, `y + tr/2`) — because ASP snaps a projwin by an edge→center conversion plus round-to-nearest, and the raw GDAL bounds of an ASP output (post `PixelIsPoint` shift) land exactly on the rounding tie, drifting the re-run grid one pixel east. The shifted box survives the snap unchanged; re-runs were verified bit-identical. Pre-3.7.0 ASP subtracts one grid size from the projwin maximum, so nothing round-trips there (and the GeoTIFF records no ASP version to adapt to)
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
@@ -100,7 +107,8 @@ The package is organized by functionality, with each module focused on a specifi
100
107
  - Creates difference maps with reference DEMs
101
108
  - Supports both map-projected and raw (non-georeferenced) imagery
102
109
  - Detects map-projection status via `Raster.transform` check
103
- - For non-mapprojected scenes: match points are overlaid on images using alignment transform matrices (`run-align-{L,R}.txt` loaded via `np.loadtxt`), and disparity plots use pixel-unit scalebar instead of GSD-based
110
+ - For non-mapprojected scenes: match points are overlaid on images using alignment transform matrices (`run-align-{L,R}.txt` loaded via `np.loadtxt`), and disparity plots use pixel-unit scalebar instead of GSD-based. Exception: when the match file is named for the aligned images themselves (`run-L__R.match`, older ASP raw-image runs that write `.exr` alignment matrices), the coordinates are already aligned — detected via `_ip_on_aligned_images()` (match stem's left name == L image stem) — and are only rescaled
111
+ - **Raw interest point overlay** (issue #8): `StereoFiles` also discovers the per-image `.vwip` files (named after the images the match file pairs — `<A>__<B>.match` ↔ `<A>.vwip`/`<prefix>-<B>.vwip` — derived from the match stem, with a `*-L.vwip`/`*-R.vwip` glob fallback when no match file exists; either side may be absent). `get_vwip_df()` parses the binary format (a uint64 count then the same interest point records as `.match`); `plot_match_points()` underlays them in blue beneath the red matches through the same per-side coordinate transform (layers denser than 10,000 points are thinned by seeded random sampling for display; titles report true counts), renders interest-points-only when the match file is missing, and falls back to the previous matches-only figure when the `.vwip` files are gone
104
112
  - **Multi-view aware** (issue #160): `StereoFiles` resolves each `<prefix>-pairN/` subdirectory into a `PairStereoFiles` (field names mirror the top-level attributes so the per-figure helpers consume either, duck-typed); `plot_match_points()` / `plot_disparity()` render one figure per pair (`<stem>_pairN.png`) and return the saved filename list — `[fig_fn]` for a standard run — mirroring the `stereo_geom_plot()` N-scene contract
105
113
  - Detects the imagery rights-holder via the `attribution` attribute (`detect_satellite_attribution`); adds the copyright overlay to optical imagery in `plot_match_points()` and `plot_detailed_hillshade()`
106
114
  - `plot_detailed_hillshade()` auto-selects three subset clips from intersection-error variance (low/medium/high) via `_auto_hillshade_clip_offsets()`. Accepts `clip_windows` (DEM-CRS bboxes) + `clip_windows_crs` to pin/replay clips for run-to-run comparison (issue #121); records the boxes it drew on `self.detailed_hillshade_clips`. Out-of-bounds pinned boxes warn and fall back to auto.
@@ -122,11 +130,15 @@ The package is organized by functionality, with each module focused on a specifi
122
130
 
123
131
  **`csm_analysis.py`** - asp_plot-specific analysis built on `csm_io`
124
132
  - `get_orbit_plot_gdf()`: turns an original/optimized camera pair into the position- and orientation-difference GeoDataFrame consumed by the plotting layer
133
+ - `read_angles_common_frame()`: the asp_plot replacement for `csm_io.read_angles()` when *differencing* two cameras (#53). ASP's `orbit_plot.py` estimates the satellite body frame per camera, from a central difference of that camera's own ephemeris; a solver both perturbs the positions and resamples the ephemeris finer, and over a ~140 m central-difference baseline a 2 m perturbation tilts that frame by ~0.8° (mostly into pitch), which swamps the orientation change being measured. Both cameras are instead put in one frame, estimated from the original ephemeris and resampled onto the optimized camera's grid. Falls back to `read_angles()` for single-sample (frame) cameras, which give no baseline to estimate a frame from
134
+ - `wrap_angle_diff()`: wraps angle differences into [-180, 180), so a camera pointing near ±180° in yaw (ASTER's backward-looking 3B band, say) does not report a ~360° change across the Euler branch cut
125
135
  - `reproject_ecef()`, `poly_fit()`
126
136
 
127
137
  **`csm_camera.py`** - Plotting layer for CSM camera optimization / jitter results
128
138
  - Compares original vs optimized CSM camera models (from bundle_adjust/jitter_solve); analyzes position/orientation differences along the trajectory. Currently supports linescan cameras (e.g., WorldView)
129
139
  - `csm_camera_summary_plot()`: the near-verbatim cam1/cam2 halves (~210 duplicated lines) collapsed into a single `_plot_camera()` called once per camera (`_apply_frame_xaxis()` for the shared linescan tick logic). Figure output verified unchanged by golden line-content characterization tests
140
+ - Map colorbar limits come from `_magnitude_limits()` **per camera**; `shared_scales=True` widens them to the union across both cameras. Camera 1's scale is not imposed on camera 2 — two cameras in one solver run often differ by more than their own spread, which flattened camera 2's track to one saturated color
141
+ - Two layout rules exist because matplotlib puts a y-axis scale/offset label in each top corner: the `"Camera N"` panel label is **centered** (right-aligned collided with the twin axis's offset), and `_no_offset_colorbar_ticks()` strips the colorbars' *additive* offset (a `1e-8+1.786e-4` string overlapped the adjacent map panel's `1e6` northing offset). Both are pinned by tests in `TestColorbarScaling` / `TestPanelTitlePlacement`
130
142
  - Re-exports the moved `csm_io`/`csm_analysis` symbols for backward compatibility with notebooks and downstream imports
131
143
 
132
144
  **Altimetry** — the 3800-line `Altimetry` god-class was split (issues #130, #140) into a thin coordinator plus a source/plotter/base layer. The public API and the `asp_plot.altimetry` re-exports are preserved by delegation, so `report_pipeline.py`, the CLI, and notebooks are unchanged.
@@ -175,9 +187,9 @@ The package is organized by functionality, with each module focused on a specifi
175
187
  **`stereo_geometry.py`** - `StereoGeometryPlotter` class
176
188
  - **Composes** a `StereopairMetadataParser` via `self.parser` rather than subclassing it (issue #25); imports `estim_satellite_orientation` from `csm_io` directly
177
189
  - 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
190
+ - `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"
191
+ - `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
192
+ - `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
193
 
182
194
  **`scenes.py`** - `ScenePlotter` class (inherits from `Plotter`)
183
195
  - Plots individual input scenes (satellite/spacecraft images)
@@ -305,7 +317,7 @@ Docs dependencies are in `pyproject.toml` under `[project.optional-dependencies]
305
317
 
306
318
  **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
319
 
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.
320
+ **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
321
 
310
322
  **Lazy Loading**: Data is loaded on-demand (e.g., DEMs, hillshades) rather than in `__init__` to avoid unnecessary I/O.
311
323
 
@@ -5,6 +5,54 @@ 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.2.0] - 2026-08-18
9
+
10
+ A correctness release for the CSM camera comparison, and a new diagnostic layer under the match points.
11
+
12
+ `csm_camera_summary_plot()` measured each camera's roll/pitch/yaw against a satellite body frame estimated separately for that camera — correct when plotting one camera, which is what ASP's `orbit_plot.py` does, but wrong for a *difference* between two ([#53](https://github.com/uw-cryo/asp_plot/issues/53)). `bundle_adjust` and `jitter_solve` both resample the ephemeris finer, and over the ~140 m central-difference baseline that leaves, a 2 m position change tilts the frame by ~0.8° — swamping the orientation change being plotted. Both cameras now share one frame, estimated from the original ephemeris, and the committed Salar de Uyuni pair's reported pitch change drops from 0.73° (ranging −3.4° to +3.6°) to 2.6e-06°, which finally says what that run did: it moved positions by ±2 m and left the orientations alone. The figure that looked full of outliers was the frame moving, not the data.
13
+
14
+ The same figure gains a `bundle_adjust` counterpart to that `jitter_solve` example, in the UCSD WorldView notebook, so the two solvers' signatures can be compared directly — and putting a second, much smaller correction next to the first exposed two readability bugs: camera 2 was drawn on camera 1's colorbar limits, and panel labels overprinted matplotlib's axis offset text.
15
+
16
+ Separately, the match point figure now underlays ASP's raw per-image interest points (`.vwip`) beneath the matches ([#8](https://github.com/uw-cryo/asp_plot/issues/8)), so a sparse match set can be traced to poor matching versus nothing detected to match; and reconstructed `mapproject` commands re-run grid-identically on ASP >= 3.7.0 instead of drifting a pixel east per run ([#148](https://github.com/uw-cryo/asp_plot/issues/148)).
17
+
18
+ A minor version: everything is additive, with no API changes and no new dependencies.
19
+
20
+ ### Added
21
+ - **A `bundle_adjust` camera-comparison example in the UCSD WorldView notebook.** The only committed `csm_camera_summary_plot()` example was a `jitter_solve` run, whose per-segment corrections oscillate along the image. `notebooks/WorldView/worldview_spacenet_ucsd_stereo.ipynb` now also compares the original and adjusted cameras from its own `bundle_adjust` run, which solves one rigid translation and rotation per camera and so produces the opposite signature: smooth, nearly flat panels with a sub-metre position offset (0.4 m north on camera 1, 0.9 m up on camera 2, matching `ba/run-camera_offsets.txt`) and a constant orientation change of ~1e-4°. Having both makes the two solvers' signatures directly comparable, and the notebook says what it means if either shows up looking like the other. Because `bundle_adjust` writes CSM state only for the *optimized* cameras, the notebook also documents how to produce the unadjusted one: re-run with a 4x4 identity `--initial-transform` and `--apply-initial-transform-only`.
22
+ - **Raw per-image interest points (`.vwip`) are overlaid on the match point figure** ([#8](https://github.com/uw-cryo/asp_plot/issues/8)). When the `.vwip` files ASP writes during interest point matching are present in the stereo directory (including per-pair in multi-view runs), the match point figure underlays them in blue beneath the red matches, with per-image counts in the panel titles — so sparse matches can be traced to either poor matching or areas with no detected interest points at all (limited texture, clouds, water). If matching failed outright and there is no match file, the raw interest points are still shown on their own. Either side's file may be absent (they are intermediates some runs clean up), in which case the figure degrades to exactly what it showed before. Layers denser than 10,000 points are thinned by seeded random sampling for display — dense runs (~100k interest points) would otherwise saturate the panels into solid color and bloat the figure — while the panel titles always report the true counts.
23
+
24
+ ### Fixed
25
+ - **Each camera's map colorbar now covers that camera's own range.** `csm_camera_summary_plot()` computed colorbar limits from camera 1 and imposed them on camera 2. Two cameras in one solver run routinely differ by more than their own spread — in the new UCSD example `bundle_adjust` moves one camera 0.432–0.454 m and the other 0.885–0.917 m — so camera 2's entire track rendered as a single saturated color with no visible spatial pattern. Limits are now per camera; passing `shared_scales=True` (which already unified the line-panel y-axes) additionally puts both cameras on one common colorbar spanning the union of their ranges, for when comparing magnitudes directly is the point.
26
+ - **Panel labels and axis offset text no longer overprint each other.** matplotlib parks a y-axis scale/offset label in each top corner of an axes, and the angle panels have two of them — the left axis's and the twin axis's — so the right-aligned `"Camera N"` title rendered on top of the twin's (`+1.669e2`), producing unreadable overstruck glyphs. The label is now centered. Separately, over a narrow range of small values a colorbar emits a combined scale-and-offset string (`1e-8+1.786e-4`) wide enough to overlap the neighbouring map panel's northing offset (`1e6`); the colorbars now drop the additive offset, leaving a short multiplier (`1e-4`) and moving the significant digits into the tick labels, where they are easier to read anyway.
27
+ - **CSM camera angle differences no longer report a rotation the camera never underwent** ([#53](https://github.com/uw-cryo/asp_plot/issues/53)). `csm_camera_summary_plot()` compared roll/pitch/yaw computed against a satellite body frame estimated *separately for each camera*, as ASP's `orbit_plot.py` does — a central difference of that camera's own ephemeris. That is fine for plotting one camera, but it corrupts a difference between two: `bundle_adjust` and `jitter_solve` both perturb the positions and resample the ephemeris to a finer spacing, and at WorldView's ~7 km/s a 0.01 s spacing leaves only a ~140 m central-difference baseline, so a 2 m radial perturbation tilts the estimated frame by ~0.8° — orders of magnitude more than the orientation change being measured, landing almost entirely in pitch. Both cameras are now expressed in one frame, estimated from the original (unperturbed) ephemeris and resampled onto the optimized camera's sample grid, so the plotted difference is the true relative rotation between the two camera models. On the committed Salar de Uyuni `jitter_solve` pair this drops the reported pitch change from 0.73° ± (range −3.4° to +3.6°) to 2.6e-06°, revealing what that run actually did: it moved the camera positions by roughly ±2 m and left the orientations untouched. This is what made the example figure look like it was full of outliers.
28
+ - **Angle differences are wrapped at ±180°** ([#53](https://github.com/uw-cryo/asp_plot/issues/53)). Euler angles are recovered on a branch cut, so a camera pointing near ±180° in yaw — a backward-looking sensor such as ASTER's 3B band — had samples straddling the cut reported as ~360° changes instead of the fraction of a degree they really were. Original angle series are also unwrapped before being resampled onto the optimized camera's grid, so a series crossing the cut is no longer interpolated through zero.
29
+ - **The match point figure no longer raises for raw-image runs whose interest points were found on the aligned images** ([#8](https://github.com/uw-cryo/asp_plot/issues/8)). Older ASP versions name the match file for the aligned images (`run-L__R.match`) and write the alignment matrices as `.exr` rather than `.txt`; those match coordinates are already in aligned space, so plotting now detects this from the match filename (left name == the L image) and just rescales instead of demanding `*-align-{L,R}.txt` and failing with `FileNotFoundError`.
30
+ - **Reconstructed `mapproject` commands now re-run grid-identically on ASP >= 3.7.0** ([#148](https://github.com/uw-cryo/asp_plot/issues/148)). The `--t_projwin` reconstructed from an output GeoTIFF's bounds did not round-trip: ASP snaps a given projwin by converting pixel edges to centers and rounding to the nearest grid multiple, and the bounds GDAL reports for an ASP output (after its `PixelIsPoint` half-pixel shift) land exactly on the rounding tie — re-running the reconstructed command drifted the grid one pixel east per run (and at fractional grid sizes, float noise could grow/shrink the raster by a pixel per edge). The reconstruction now emits ASP's own pixel-edge box — the bounds shifted half a pixel NW (`x − tr/2`, `y + tr/2`) — which survives ASP's snap unchanged; re-runs were verified bit-identical (grid, extent, and pixel values) at both whole and fractional grid sizes. On pre-3.7.0 ASP, which subtracted one grid size from the projwin maximum, no projwin choice can round-trip; the report's explanatory note now states the version assumption.
31
+
32
+ ## [2.1.0] - 2026-07-30
33
+
34
+ `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.
35
+
36
+ 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.
37
+
38
+ Everything is additive — no API changes, and no new dependencies.
39
+
40
+ ### Added
41
+ - **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.
42
+ - **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.
43
+ - **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.
44
+ - **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 ✅).
45
+
46
+ ### Changed
47
+ - **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".
48
+ - **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.
49
+ - **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").
50
+ - **`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()`).
51
+
52
+ ### Fixed
53
+ - **"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.
54
+ - **`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.
55
+
8
56
  ## [2.0.0] - 2026-07-29
9
57
 
10
58
  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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: asp_plot
3
- Version: 2.0.0
3
+ Version: 2.2.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]
@@ -4,8 +4,13 @@ position- and orientation-difference GeoDataFrame consumed by the plotting
4
4
  layer (``csm_camera.py``).
5
5
 
6
6
  This module owns the asp_plot-specific analysis (``get_orbit_plot_gdf``,
7
- ``reproject_ecef``, ``poly_fit``) and builds on the ASP-mirrored readers in
8
- ``csm_io.py``.
7
+ ``read_angles_common_frame``, ``wrap_angle_diff``, ``reproject_ecef``,
8
+ ``poly_fit``) and builds on the ASP-mirrored readers in ``csm_io.py``.
9
+
10
+ ``read_angles_common_frame`` is a deliberate divergence from ASP's
11
+ ``orbit_plot.py``: differencing two cameras requires them to share one reference
12
+ frame, and ASP estimates a separate frame per camera from that camera's own
13
+ ephemeris. See its docstring and issue #53.
9
14
  """
10
15
 
11
16
  import geopandas as gpd
@@ -15,14 +20,129 @@ from pyproj import Transformer
15
20
  from shapely.geometry import Point
16
21
 
17
22
  from asp_plot.csm_io import (
23
+ estim_satellite_orientation,
18
24
  getTimeAtLine,
19
25
  isLinescan,
20
26
  read_angles,
21
27
  read_csm_cam,
22
28
  read_positions_rotations,
29
+ roll_pitch_yaw,
23
30
  )
24
31
 
25
32
 
33
+ def _resample(values, n_out):
34
+ """Resample an (n_in, k) array onto ``n_out`` samples spanning the same range."""
35
+ values = np.asarray(values, dtype=float)
36
+ n_in = values.shape[0]
37
+ if n_in == n_out:
38
+ return values
39
+ x_out = np.linspace(0, 1, n_out)
40
+ x_in = np.linspace(0, 1, n_in)
41
+ return np.column_stack(
42
+ [np.interp(x_out, x_in, values[:, i]) for i in range(values.shape[1])]
43
+ )
44
+
45
+
46
+ def wrap_angle_diff(angles):
47
+ """
48
+ Wrap angle differences in degrees into the [-180, 180) range.
49
+
50
+ Parameters
51
+ ----------
52
+ angles : array-like
53
+ Angle differences in degrees
54
+
55
+ Returns
56
+ -------
57
+ numpy.ndarray
58
+ The same differences, wrapped into [-180, 180)
59
+
60
+ Notes
61
+ -----
62
+ Euler angles are recovered on a branch cut, so a camera whose yaw sits near
63
+ +/-180 degrees (a backward-looking sensor such as ASTER's 3B band, for
64
+ example) can have adjacent samples reported as +179.9 and -179.9. Without
65
+ wrapping, an orientation change of 0.2 degrees is plotted as ~360 degrees.
66
+ """
67
+ return (np.asarray(angles, dtype=float) + 180.0) % 360.0 - 180.0
68
+
69
+
70
+ def read_angles_common_frame(original_camera, optimized_camera):
71
+ """
72
+ Read roll/pitch/yaw for a camera pair using a single shared reference frame.
73
+
74
+ Parameters
75
+ ----------
76
+ original_camera : str
77
+ Path to the original camera file
78
+ optimized_camera : str
79
+ Path to the optimized camera file
80
+
81
+ Returns
82
+ -------
83
+ tuple of numpy.ndarray
84
+ ``(original_angles, optimized_angles)``, each of shape (n, 3) holding
85
+ roll, pitch and yaw in degrees
86
+
87
+ Notes
88
+ -----
89
+ ASP's ``orbit_plot.py`` (mirrored in ``csm_io.read_angles()``) estimates the
90
+ satellite body frame separately for each camera, from a central difference
91
+ of that camera's own ephemeris. That is fine when plotting one camera, but
92
+ it corrupts a *difference* between two cameras: ``bundle_adjust`` and
93
+ ``jitter_solve`` both perturb the positions and usually resample the
94
+ ephemeris to a finer spacing, and a small position perturbation over a short
95
+ baseline tilts the estimated along-track axis a lot. At WorldView's ~7 km/s
96
+ and a 0.01 s sample spacing the baseline is only ~140 m, so a 2 m radial
97
+ perturbation swings the estimated frame by ~0.8 degrees -- orders of
98
+ magnitude more than the orientation change actually being measured, and it
99
+ lands almost entirely in pitch.
100
+
101
+ Here both cameras are instead expressed in one frame, estimated from the
102
+ original (unperturbed) ephemeris and resampled onto the optimized camera's
103
+ sample grid. The reported angle difference is then the true relative
104
+ rotation between the two camera models, not a difference of two different
105
+ reference frames.
106
+ """
107
+ original_positions, original_rotations = read_positions_rotations([original_camera])
108
+ optimized_positions, optimized_rotations = read_positions_rotations(
109
+ [optimized_camera]
110
+ )
111
+ original_positions = np.array(original_positions, dtype=float)
112
+
113
+ # A single-sample (frame) camera gives no baseline to estimate a satellite
114
+ # frame from, so fall back to ASP's behavior. Guard on *both* cameras: a
115
+ # one-sample optimized camera would collapse the resample below to a single
116
+ # point, which has the same zero-length tangent vector problem. Neither path
117
+ # produces usable angles for a frame camera -- ASP's own
118
+ # estim_satellite_orientation divides by zero there too -- so this only
119
+ # keeps the two cameras treated alike.
120
+ if len(original_positions) < 2 or len(optimized_rotations) < 2:
121
+ original_angles, optimized_angles = read_angles(
122
+ [original_camera], [optimized_camera], []
123
+ )
124
+ return np.array(original_angles), np.array(optimized_angles)
125
+
126
+ original_ref_rotations = estim_satellite_orientation(original_positions)
127
+ optimized_ref_rotations = estim_satellite_orientation(
128
+ _resample(original_positions, len(optimized_rotations))
129
+ )
130
+
131
+ original_angles = np.array(
132
+ [
133
+ roll_pitch_yaw(original_rotations[i], original_ref_rotations[i])
134
+ for i in range(len(original_rotations))
135
+ ]
136
+ )
137
+ optimized_angles = np.array(
138
+ [
139
+ roll_pitch_yaw(optimized_rotations[i], optimized_ref_rotations[i])
140
+ for i in range(len(optimized_rotations))
141
+ ]
142
+ )
143
+ return original_angles, optimized_angles
144
+
145
+
26
146
  def reproject_ecef(positions, to_epsg=4326):
27
147
  """
28
148
  Reproject ECEF coordinates to a specified EPSG coordinate system.
@@ -79,12 +199,13 @@ def get_orbit_plot_gdf(original_camera, optimized_camera, map_crs=None, trim=Tru
79
199
  cameras, it optionally trims the data to only include samples corresponding
80
200
  to the actual image lines.
81
201
  """
82
- # orbit_plot.py method to get angles in NED
83
- # https://github.com/NeoGeographyToolkit/StereoPipeline/blob/master/src/asp/Tools/orbit_plot.py#L412
84
- # This method already calls read_positions_rotations below, but it
85
- # doesn't return the positions and rotations we want for plotting
86
- original_rotation_angles, optimized_rotation_angles = read_angles(
87
- [original_camera], [optimized_camera], []
202
+ # Roll/pitch/yaw for both cameras, expressed in a single satellite frame
203
+ # estimated from the original ephemeris. This follows orbit_plot.py's
204
+ # read_angles() (mirrored in csm_io) but shares one reference frame between
205
+ # the two cameras, so the difference below is not contaminated by the
206
+ # frame itself moving. See read_angles_common_frame() for why.
207
+ original_rotation_angles, optimized_rotation_angles = read_angles_common_frame(
208
+ original_camera, optimized_camera
88
209
  )
89
210
 
90
211
  # orbit_plot.py method to get positions and rotations
@@ -142,28 +263,26 @@ def get_orbit_plot_gdf(original_camera, optimized_camera, map_crs=None, trim=Tru
142
263
  optimized_pitch = np.array([r[1] for r in optimized_rotation_angles])
143
264
  optimized_yaw = np.array([r[2] for r in optimized_rotation_angles])
144
265
 
145
- # Interpolate original angles if lengths don't match
266
+ # Interpolate original angles if lengths don't match. Unwrap first so a
267
+ # series straddling the +/-180 branch cut is not averaged through zero,
268
+ # then wrap the result back into [-180, 180).
146
269
  if len(original_roll) != len(optimized_roll):
147
- original_roll = np.interp(
148
- np.linspace(0, 1, len(optimized_roll)),
149
- np.linspace(0, 1, len(original_roll)),
150
- original_roll,
151
- )
152
- original_pitch = np.interp(
153
- np.linspace(0, 1, len(optimized_pitch)),
154
- np.linspace(0, 1, len(original_pitch)),
155
- original_pitch,
156
- )
157
- original_yaw = np.interp(
158
- np.linspace(0, 1, len(optimized_yaw)),
159
- np.linspace(0, 1, len(original_yaw)),
160
- original_yaw,
270
+ original_angles = _resample(
271
+ np.unwrap(
272
+ np.column_stack([original_roll, original_pitch, original_yaw]),
273
+ period=360.0,
274
+ axis=0,
275
+ ),
276
+ len(optimized_roll),
161
277
  )
278
+ original_roll, original_pitch, original_yaw = wrap_angle_diff(original_angles).T
162
279
 
163
- # We are interested in the difference between the original and optimized angles
164
- roll_diff = original_roll - optimized_roll
165
- pitch_diff = original_pitch - optimized_pitch
166
- yaw_diff = original_yaw - optimized_yaw
280
+ # We are interested in the difference between the original and optimized
281
+ # angles. Wrap the differences so a camera pointing near +/-180 degrees in
282
+ # yaw does not report a ~360 degree change across the branch cut.
283
+ roll_diff = wrap_angle_diff(original_roll - optimized_roll)
284
+ pitch_diff = wrap_angle_diff(original_pitch - optimized_pitch)
285
+ yaw_diff = wrap_angle_diff(original_yaw - optimized_yaw)
167
286
 
168
287
  # Also get angular diff magnitude
169
288
  angular_diff_magnitudes = np.sqrt(roll_diff**2 + pitch_diff**2 + yaw_diff**2)