asp-plot 2.1.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 (58) hide show
  1. {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/workflows/claude-code-review.yml +1 -1
  2. {asp_plot-2.1.0 → asp_plot-2.2.0}/AGENTS.md +2 -0
  3. {asp_plot-2.1.0 → asp_plot-2.2.0}/ARCHITECTURE.md +8 -3
  4. {asp_plot-2.1.0 → asp_plot-2.2.0}/CHANGELOG.md +24 -0
  5. {asp_plot-2.1.0 → asp_plot-2.2.0}/PKG-INFO +2 -2
  6. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/csm_analysis.py +146 -27
  7. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/csm_camera.py +103 -26
  8. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/mapproject.py +38 -6
  9. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/report.py +2 -1
  10. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/report_captions.py +1 -1
  11. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/stereo.py +266 -73
  12. {asp_plot-2.1.0 → asp_plot-2.2.0}/pyproject.toml +1 -1
  13. {asp_plot-2.1.0 → asp_plot-2.2.0}/.flake8 +0 -0
  14. {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  15. {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/ISSUE_TEMPLATE/problem-report.yml +0 -0
  16. {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/workflows/claude.yml +0 -0
  17. {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/workflows/release.yml +0 -0
  18. {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/workflows/run-tests.yml +0 -0
  19. {asp_plot-2.1.0 → asp_plot-2.2.0}/.gitignore +0 -0
  20. {asp_plot-2.1.0 → asp_plot-2.2.0}/.pre-commit-config.yaml +0 -0
  21. {asp_plot-2.1.0 → asp_plot-2.2.0}/.readthedocs.yaml +0 -0
  22. {asp_plot-2.1.0 → asp_plot-2.2.0}/CLAUDE.md +0 -0
  23. {asp_plot-2.1.0 → asp_plot-2.2.0}/LICENSE +0 -0
  24. {asp_plot-2.1.0 → asp_plot-2.2.0}/README.md +0 -0
  25. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/__init__.py +0 -0
  26. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/alignment.py +0 -0
  27. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/altimetry.py +0 -0
  28. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/altimetry_plots.py +0 -0
  29. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/altimetry_source.py +0 -0
  30. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/asp_log.py +0 -0
  31. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/bodies.py +0 -0
  32. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/bundle_adjust.py +0 -0
  33. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/__init__.py +0 -0
  34. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/asp_report.py +0 -0
  35. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/csm_camera_plot.py +0 -0
  36. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/gallery.py +0 -0
  37. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
  38. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/stereo_geom.py +0 -0
  39. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/csm_io.py +0 -0
  40. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/gallery.py +0 -0
  41. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/icesat2_source.py +0 -0
  42. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/planetary_source.py +0 -0
  43. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/processing_parameters.py +0 -0
  44. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/report_pipeline.py +0 -0
  45. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/scenes.py +0 -0
  46. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/selections.py +0 -0
  47. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/__init__.py +0 -0
  48. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/aster.py +0 -0
  49. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/base.py +0 -0
  50. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/dimap.py +0 -0
  51. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/dimap_v1.py +0 -0
  52. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/rpc.py +0 -0
  53. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/worldview.py +0 -0
  54. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/stereo_geometry.py +0 -0
  55. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/stereopair_metadata_parser.py +0 -0
  56. {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/utils.py +0 -0
  57. {asp_plot-2.1.0 → asp_plot-2.2.0}/conda-forge-recipe/meta.yaml +0 -0
  58. {asp_plot-2.1.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
@@ -46,6 +46,8 @@ sphinx-autobuild docs docs/_build/html --open-browser # or sphinx-build for a
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
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
 
@@ -77,8 +77,8 @@ 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
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)
@@ -107,7 +107,8 @@ The package is organized by functionality, with each module focused on a specifi
107
107
  - Creates difference maps with reference DEMs
108
108
  - Supports both map-projected and raw (non-georeferenced) imagery
109
109
  - Detects map-projection status via `Raster.transform` check
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
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
111
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
112
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()`
113
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.
@@ -129,11 +130,15 @@ The package is organized by functionality, with each module focused on a specifi
129
130
 
130
131
  **`csm_analysis.py`** - asp_plot-specific analysis built on `csm_io`
131
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
132
135
  - `reproject_ecef()`, `poly_fit()`
133
136
 
134
137
  **`csm_camera.py`** - Plotting layer for CSM camera optimization / jitter results
135
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)
136
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`
137
142
  - Re-exports the moved `csm_io`/`csm_analysis` symbols for backward compatibility with notebooks and downstream imports
138
143
 
139
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.
@@ -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.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
+
8
32
  ## [2.1.0] - 2026-07-30
9
33
 
10
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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: asp_plot
3
- Version: 2.1.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
@@ -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)
@@ -15,11 +15,14 @@ import numpy as np
15
15
  from matplotlib import pyplot as plt
16
16
  from matplotlib.cm import ScalarMappable
17
17
  from matplotlib.colors import Normalize
18
+ from matplotlib.ticker import ScalarFormatter
18
19
 
19
20
  from asp_plot.csm_analysis import ( # noqa: F401 (re-exported for back-compat)
20
21
  get_orbit_plot_gdf,
21
22
  poly_fit,
23
+ read_angles_common_frame,
22
24
  reproject_ecef,
25
+ wrap_angle_diff,
23
26
  )
24
27
  from asp_plot.csm_io import ( # noqa: F401 (re-exported for back-compat)
25
28
  ASP_TO_CSM_SHIFT,
@@ -124,6 +127,60 @@ def _apply_frame_xaxis(ax, gdf, frame):
124
127
  ax.set_xlabel("Position Sample", fontsize=9)
125
128
 
126
129
 
130
+ def _no_offset_colorbar_ticks(cbar):
131
+ """
132
+ Stop a colorbar from labelling its ticks with an additive offset.
133
+
134
+ Parameters
135
+ ----------
136
+ cbar : matplotlib.colorbar.Colorbar
137
+ The colorbar to reformat
138
+
139
+ Notes
140
+ -----
141
+ Over a narrow range of small values -- angle changes of 1.7860e-4 to
142
+ 1.7870e-4 degrees, say -- matplotlib labels the ticks 0.5, 1.0, 1.5 and
143
+ parks a combined scale-and-offset string ("1e-8+1.786e-4") above the
144
+ colorbar, where it collides with the adjacent map panel's own northing
145
+ offset ("1e6"). Dropping the additive offset leaves a bare multiplier
146
+ ("1e-4"), which is both short enough to clear the neighbour and easier to
147
+ read: the ticks then carry the significant digits themselves.
148
+ """
149
+ formatter = ScalarFormatter(useOffset=False)
150
+ formatter.set_powerlimits((-3, 4))
151
+ cbar.ax.yaxis.set_major_formatter(formatter)
152
+
153
+
154
+ def _magnitude_limits(magnitudes, upper_magnitude_percentile):
155
+ """
156
+ Colorbar limits for one camera's difference magnitudes.
157
+
158
+ Parameters
159
+ ----------
160
+ magnitudes : pandas.Series
161
+ A ``position_diff_magnitude`` or ``angular_diff_magnitude`` column
162
+ upper_magnitude_percentile : int
163
+ Percentile used for the upper limit
164
+
165
+ Returns
166
+ -------
167
+ tuple of float
168
+ ``(vmin, vmax)``, or ``(0, 0)`` when nothing changed
169
+
170
+ Notes
171
+ -----
172
+ Exact zeros are excluded before taking percentiles, and an all-zero column
173
+ (a camera the solver left untouched) returns ``(0, 0)`` rather than raising
174
+ on the empty percentile (issue #54).
175
+ """
176
+ values = magnitudes[magnitudes > 0]
177
+ try:
178
+ vmin, vmax = np.percentile(values, [0, upper_magnitude_percentile])
179
+ except IndexError:
180
+ vmin, vmax = 0, 0
181
+ return vmin, vmax
182
+
183
+
127
184
  def _plot_camera(
128
185
  pos_row,
129
186
  ang_row,
@@ -203,6 +260,7 @@ def _plot_camera(
203
260
  )
204
261
  cbar1.set_label("Diff Magnitude (m)", fontsize=9)
205
262
  cbar1.ax.tick_params(labelsize=9)
263
+ _no_offset_colorbar_ticks(cbar1)
206
264
 
207
265
  # Angle-magnitude mapview plot
208
266
  ax = ang_row[0]
@@ -228,6 +286,7 @@ def _plot_camera(
228
286
  )
229
287
  cbar2.set_label("Diff Magnitude (°)", fontsize=9)
230
288
  cbar2.ax.tick_params(labelsize=9)
289
+ _no_offset_colorbar_ticks(cbar2)
231
290
 
232
291
  frame = np.arange(gdf.shape[0])
233
292
 
@@ -263,7 +322,11 @@ def _plot_camera(
263
322
  for ax in [ax1, ax2, ax3]:
264
323
  ax.set_xlim(frame.min(), frame.max())
265
324
  ax.hlines(0, frame.min(), frame.max(), color="k", linestyle="-", lw=0.5)
266
- ax.set_title(cam_label, loc="right", fontsize=10, y=0.98)
325
+ # Centered, not right-aligned: matplotlib parks a y-axis offset/scale
326
+ # label ("1e-5", "+1.669e2") in each top corner, and the angle panels
327
+ # have two of them -- the left axis's at top left and the twin axis's
328
+ # at top right -- so a right-aligned title renders on top of the latter.
329
+ ax.set_title(cam_label, loc="center", fontsize=10)
267
330
  _apply_frame_xaxis(ax, gdf, frame)
268
331
  ax.set_ylabel("Original $-$ Optimized (m)", fontsize=9)
269
332
  if shared_scales:
@@ -310,7 +373,11 @@ def _plot_camera(
310
373
  for ax, ax_r in [(ax1, ax1_r), (ax2, ax2_r), (ax3, ax3_r)]:
311
374
  ax.set_xlim(frame.min(), frame.max())
312
375
  ax.hlines(0, frame.min(), frame.max(), color="k", linestyle="-", lw=0.5)
313
- ax.set_title(cam_label, loc="right", fontsize=10, y=0.98)
376
+ # Centered, not right-aligned: matplotlib parks a y-axis offset/scale
377
+ # label ("1e-5", "+1.669e2") in each top corner, and the angle panels
378
+ # have two of them -- the left axis's at top left and the twin axis's
379
+ # at top right -- so a right-aligned title renders on top of the latter.
380
+ ax.set_title(cam_label, loc="center", fontsize=10)
314
381
  _apply_frame_xaxis(ax, gdf, frame)
315
382
  ax.set_ylabel("Original $-$ Optimized (°)", fontsize=9)
316
383
  if shared_scales:
@@ -408,28 +475,38 @@ def csm_camera_summary_plot(
408
475
  )
409
476
  add_basemap = False
410
477
 
411
- # Calculate colorbar ranges from camera 1; camera 2 reuses the same scale.
412
- position_values = gdf_cam1.position_diff_magnitude[
413
- gdf_cam1.position_diff_magnitude > 0
414
- ]
415
- angular_values = gdf_cam1.angular_diff_magnitude[
416
- gdf_cam1.angular_diff_magnitude > 0
417
- ]
418
- # When position or angular changes are all zero, the percentile calculation will fail
419
- # https://github.com/uw-cryo/asp_plot/issues/54
420
- try:
421
- cam1_position_vmin, cam1_position_vmax = np.percentile(
422
- position_values, [0, upper_magnitude_percentile]
478
+ # Colorbar ranges are per camera by default. Two cameras in one run can
479
+ # differ by more than their own spread -- bundle_adjust might move one by
480
+ # 0.44 m and the other by 0.90 m -- and forcing the second onto the first's
481
+ # scale paints its whole track a single saturated color, hiding any spatial
482
+ # pattern. shared_scales puts both on one scale when the point is to compare
483
+ # their magnitudes directly.
484
+ cam1_position_vmin, cam1_position_vmax = _magnitude_limits(
485
+ gdf_cam1.position_diff_magnitude, upper_magnitude_percentile
486
+ )
487
+ cam1_angular_vmin, cam1_angular_vmax = _magnitude_limits(
488
+ gdf_cam1.angular_diff_magnitude, upper_magnitude_percentile
489
+ )
490
+ if cam2_list:
491
+ cam2_position_vmin, cam2_position_vmax = _magnitude_limits(
492
+ gdf_cam2.position_diff_magnitude, upper_magnitude_percentile
423
493
  )
424
- except IndexError:
425
- cam1_position_vmin, cam1_position_vmax = 0, 0
426
-
427
- try:
428
- cam1_angular_vmin, cam1_angular_vmax = np.percentile(
429
- angular_values, [0, upper_magnitude_percentile]
494
+ cam2_angular_vmin, cam2_angular_vmax = _magnitude_limits(
495
+ gdf_cam2.angular_diff_magnitude, upper_magnitude_percentile
430
496
  )
431
- except IndexError:
432
- cam1_angular_vmin, cam1_angular_vmax = 0, 0
497
+ if shared_scales:
498
+ cam1_position_vmin = cam2_position_vmin = min(
499
+ cam1_position_vmin, cam2_position_vmin
500
+ )
501
+ cam1_position_vmax = cam2_position_vmax = max(
502
+ cam1_position_vmax, cam2_position_vmax
503
+ )
504
+ cam1_angular_vmin = cam2_angular_vmin = min(
505
+ cam1_angular_vmin, cam2_angular_vmin
506
+ )
507
+ cam1_angular_vmax = cam2_angular_vmax = max(
508
+ cam1_angular_vmax, cam2_angular_vmax
509
+ )
433
510
 
434
511
  if upper_magnitude_percentile == 100:
435
512
  extend = "neither"
@@ -467,10 +544,10 @@ def csm_camera_summary_plot(
467
544
  gdf_cam2,
468
545
  cam2_name,
469
546
  "Camera 2",
470
- cam1_position_vmin,
471
- cam1_position_vmax,
472
- cam1_angular_vmin,
473
- cam1_angular_vmax,
547
+ cam2_position_vmin,
548
+ cam2_position_vmax,
549
+ cam2_angular_vmin,
550
+ cam2_angular_vmax,
474
551
  extend,
475
552
  add_basemap,
476
553
  shared_scales,
@@ -15,13 +15,28 @@ ASP ``--log`` flag required. The fields written by ASP are:
15
15
  - ``BUNDLE_ADJUST_PREFIX`` -- the ``--bundle-adjust-prefix`` (``"NONE"`` if unset)
16
16
 
17
17
  combined with the raster's own CRS (``--t_srs``), resolution (``--tr``), and
18
- bounds (``--t_projwin``).
18
+ bounds (``--t_projwin``, shifted half a pixel NW to ASP's pixel-edge
19
+ convention -- see below).
19
20
 
20
21
  The reconstruction is faithful but *not* byte-for-byte re-runnable: the session
21
22
  type is the resolved value (not necessarily what the user typed), an input
22
23
  ``--mpp`` shows up resolved as ``--tr``, and the output name is read from the
23
24
  file itself. Callers that surface this to users (e.g. the PDF report) should say
24
25
  so. See ``reconstruct_mapproject_command`` for the exact argv order.
26
+
27
+ The emitted ``--t_projwin`` is, however, *grid-exact*: re-running the
28
+ reconstructed command with ASP >= 3.7.0 reproduces the output's grid, extent,
29
+ and pixel values identically (verified empirically for #148). That requires
30
+ emitting ASP's pixel-edge box -- the GDAL-reported bounds shifted half a pixel
31
+ NW -- because ASP snaps a given projwin via an edge-to-center conversion plus
32
+ round-to-nearest, and the raw GDAL bounds of an ASP output land exactly on the
33
+ rounding tie (drifting the grid one pixel east per re-run). Two version caveats:
34
+ ASP < 3.7.0 instead subtracts one grid size from the projwin maximum, so no
35
+ projwin choice round-trips there (worst case: a one-pixel extent difference,
36
+ identical pixel values on the shared grid); and the outputs of pre-3.7.0
37
+ explicit-``--t_projwin`` runs may sit on a grid phase that a modern re-run snaps
38
+ up to half a pixel. The output GeoTIFF records no ASP version, so the
39
+ reconstruction targets current semantics unconditionally.
25
40
  """
26
41
 
27
42
  import glob
@@ -96,6 +111,11 @@ def reconstruct_mapproject_command(raster_path):
96
111
  ``--t_srs`` is emitted as ``EPSG:XXXX`` when the CRS has an exact EPSG code,
97
112
  otherwise as the PROJ string (quoted), so custom planetary/local projections
98
113
  (e.g. the stereographic frames used in jitter solving) still round-trip.
114
+
115
+ ``--t_projwin`` is the raster's bounds shifted half a pixel NW (x - tr/2,
116
+ y + tr/2): ASP's pixel-edge box, which re-runs grid-identically on
117
+ ASP >= 3.7.0 instead of drifting one pixel east (#148; see the module
118
+ docstring for the full convention story and pre-3.7.0 caveats).
99
119
  """
100
120
  # Reuse the package Raster wrapper: it suppresses the NotGeoreferencedWarning
101
121
  # that the raw (non-georef) input scenes raise during discovery, and it owns
@@ -122,14 +142,26 @@ def reconstruct_mapproject_command(raster_path):
122
142
  if t_srs:
123
143
  parts += ["--t_srs", t_srs]
124
144
 
125
- parts += ["--tr", _format_coord(raster.get_gsd())]
145
+ gsd = raster.get_gsd()
146
+ parts += ["--tr", _format_coord(gsd)]
147
+ # Emit --t_projwin as ASP's pixel-EDGE box (pixel centers +- tr/2), not
148
+ # the GDAL-reported bounds. ASP snaps a given projwin by converting
149
+ # edges to centers (+ tr/2) and rounding to the nearest grid multiple;
150
+ # the bounds GDAL reports for an ASP output (after its PixelIsPoint
151
+ # half-pixel shift) land exactly on that rounding tie, so re-running
152
+ # with them drifts the grid one pixel east per run (and float noise at
153
+ # fractional grid sizes can flip individual edges either way). Shifting
154
+ # the window half a pixel NW yields the box ASP itself passes to its
155
+ # mapproject_single tiles; it survives the snap unchanged, and the
156
+ # re-run grid is identical (#148, ASP >= 3.7.0 -- see module docstring).
157
+ half = gsd / 2
126
158
  bounds = raster.ds.bounds
127
159
  parts += [
128
160
  "--t_projwin",
129
- _format_coord(bounds.left),
130
- _format_coord(bounds.bottom),
131
- _format_coord(bounds.right),
132
- _format_coord(bounds.top),
161
+ _format_coord(bounds.left - half),
162
+ _format_coord(bounds.bottom + half),
163
+ _format_coord(bounds.right - half),
164
+ _format_coord(bounds.top + half),
133
165
  ]
134
166
 
135
167
  ba_prefix = tags.get("BUNDLE_ADJUST_PREFIX")
@@ -493,7 +493,8 @@ def _add_processing_parameters_page(pdf, params, report_command):
493
493
  0,
494
494
  4,
495
495
  "Reconstructed from output GeoTIFF metadata (mapproject writes no "
496
- "log file); session type and grid size are the resolved values.",
496
+ "log file); session type and grid size are the resolved values. "
497
+ "Re-running reproduces the output grid exactly with ASP >= 3.7.0.",
497
498
  new_x="LMARGIN",
498
499
  new_y="NEXT",
499
500
  )
@@ -17,7 +17,7 @@ INPUT_SCENES = "Left and right input scenes used for stereo processing. Non-mapp
17
17
 
18
18
  STEREO_GEOMETRY = "Stereo acquisition geometry skyplot and map view showing satellite viewing angles and scene footprints."
19
19
 
20
- MATCH_POINTS = "Interest point matches between left and right images. These are produced by stereo_corr during its initial interest point matching step, which is used to set the search windows for subsequent dense correlation (not the dense correlation matches themselves)."
20
+ MATCH_POINTS = "Interest point matches between left and right images (red), overlaid on the raw interest points detected on each image individually (blue, when the .vwip files are present in the stereo directory). These are produced by stereo_corr during its initial interest point matching step, which is used to set the search windows for subsequent dense correlation (not the dense correlation matches themselves). Comparing the raw interest points against the matches shows whether sparse matching traces back to poor matching or to areas with no detected interest points at all (e.g., limited texture, clouds, water). Some ASP configurations only write a .vwip file for the left image (the right image's interest points are detected on a temporary aligned image and not saved), in which case only matches are shown on the right."
21
21
 
22
22
  BUNDLE_RESIDUALS_LOG = (
23
23
  "Initial and final bundle adjustment residuals on a logarithmic scale."
@@ -29,6 +29,17 @@ from asp_plot.utils import (
29
29
  logging.basicConfig(level=logging.WARNING)
30
30
  logger = logging.getLogger(__name__)
31
31
 
32
+ # Overlay colors for the match point figure, chosen to stand out against the
33
+ # grayscale images.
34
+ INTEREST_POINT_COLOR = "deepskyblue"
35
+ MATCH_POINT_COLOR = "r"
36
+ # At most this many markers are drawn per overlay layer: dense runs (e.g.
37
+ # ~100k interest points) saturate the panels into solid color otherwise.
38
+ # Layers are thinned by seeded random sampling, which preserves the spatial
39
+ # coverage the figure exists to show; panel titles always report the true
40
+ # counts.
41
+ MAX_POINTS_DRAWN = 10000
42
+
32
43
 
33
44
  @dataclass
34
45
  class PairStereoFiles:
@@ -49,6 +60,8 @@ class PairStereoFiles:
49
60
  align_left_fn: Optional[str]
50
61
  align_right_fn: Optional[str]
51
62
  match_point_fn: Optional[str]
63
+ left_vwip_fn: Optional[str]
64
+ right_vwip_fn: Optional[str]
52
65
  disparity_sub_fn: Optional[str]
53
66
  disparity_fn: Optional[str]
54
67
 
@@ -86,6 +99,10 @@ class StereoFiles:
86
99
  Match-point file (the non-``-disp-`` one when several exist); None when
87
100
  the directory has none at the top level (e.g. a multi-view run, whose
88
101
  match files live in the ``run-pair*/`` subdirectories).
102
+ left_vwip_fn, right_vwip_fn : str or None
103
+ Per-image raw interest point files (``.vwip``) for the left and right
104
+ images; either may be None (they are intermediates that ASP runs
105
+ sometimes clean up, and some runs keep only the left file).
89
106
  disparity_sub_fn, disparity_fn : str or None
90
107
  Sub-sampled and full disparity map paths.
91
108
  pairs : list of PairStereoFiles
@@ -179,6 +196,9 @@ class StereoFiles:
179
196
  )
180
197
 
181
198
  self.match_point_fn = self._find_match_file(self.full_directory, quiet=quiet)
199
+ self.left_vwip_fn, self.right_vwip_fn = self._find_vwip_files(
200
+ self.full_directory, self.match_point_fn
201
+ )
182
202
 
183
203
  self.disparity_sub_fn = glob_file(
184
204
  self.full_directory, "*-D_sub.tif", quiet=quiet
@@ -187,22 +207,29 @@ class StereoFiles:
187
207
  # and rescaling below.
188
208
  self.disparity_fn = glob_file(self.full_directory, "*-D.tif", quiet=quiet)
189
209
 
190
- self.pairs = [
191
- PairStereoFiles(
192
- number=number,
193
- directory=pair_directory,
194
- label=describe_pair(number, pair_directory),
195
- left_image_fn=glob_file(pair_directory, "*-L.tif"),
196
- left_image_sub_fn=glob_file(pair_directory, "*-L_sub.tif"),
197
- right_image_sub_fn=glob_file(pair_directory, "*-R_sub.tif"),
198
- align_left_fn=glob_file(pair_directory, "*-align-L.txt"),
199
- align_right_fn=glob_file(pair_directory, "*-align-R.txt"),
200
- match_point_fn=self._find_match_file(pair_directory),
201
- disparity_sub_fn=glob_file(pair_directory, "*-D_sub.tif"),
202
- disparity_fn=glob_file(pair_directory, "*-D.tif"),
210
+ self.pairs = []
211
+ for number, pair_directory in pair_directories:
212
+ match_point_fn = self._find_match_file(pair_directory)
213
+ left_vwip_fn, right_vwip_fn = self._find_vwip_files(
214
+ pair_directory, match_point_fn
215
+ )
216
+ self.pairs.append(
217
+ PairStereoFiles(
218
+ number=number,
219
+ directory=pair_directory,
220
+ label=describe_pair(number, pair_directory),
221
+ left_image_fn=glob_file(pair_directory, "*-L.tif"),
222
+ left_image_sub_fn=glob_file(pair_directory, "*-L_sub.tif"),
223
+ right_image_sub_fn=glob_file(pair_directory, "*-R_sub.tif"),
224
+ align_left_fn=glob_file(pair_directory, "*-align-L.txt"),
225
+ align_right_fn=glob_file(pair_directory, "*-align-R.txt"),
226
+ match_point_fn=match_point_fn,
227
+ left_vwip_fn=left_vwip_fn,
228
+ right_vwip_fn=right_vwip_fn,
229
+ disparity_sub_fn=glob_file(pair_directory, "*-D_sub.tif"),
230
+ disparity_fn=glob_file(pair_directory, "*-D.tif"),
231
+ )
203
232
  )
204
- for number, pair_directory in pair_directories
205
- ]
206
233
 
207
234
  self.dem_gsd = dem_gsd
208
235
 
@@ -238,12 +265,59 @@ class StereoFiles:
238
265
 
239
266
  There may be multiple match files if stereo was run with
240
267
  ``--num-matches-from-disparity``; in that case, filter out the match
241
- file with ``-disp-`` in the filename.
268
+ file with ``-disp-`` in the filename. Candidates are sorted so the
269
+ choice is deterministic (glob order is filesystem-dependent).
242
270
  """
243
271
  match_files = glob_file(directory, "*.match", all_files=True, quiet=quiet)
244
- non_disp = [f for f in (match_files or []) if "-disp-" not in f]
272
+ non_disp = sorted(f for f in (match_files or []) if "-disp-" not in f)
245
273
  return non_disp[0] if non_disp else None
246
274
 
275
+ @staticmethod
276
+ def _find_vwip_files(directory, match_point_fn):
277
+ """The directory's left and right ``.vwip`` files, or None for each.
278
+
279
+ ASP writes the per-image raw interest points next to the match file
280
+ that pairs them: ``<A>__<B>.match`` sits alongside ``<A>.vwip`` and
281
+ ``<prefix>-<B>.vwip`` (``A``/``B`` are ``L``/``R`` when interest
282
+ points were found on the aligned images, the input image basenames
283
+ otherwise). Either side may be absent — they are intermediates that
284
+ some runs clean up or only partially keep — so lookups are quiet.
285
+ """
286
+ vwip_files = sorted(
287
+ glob_file(directory, "*.vwip", all_files=True, quiet=True) or []
288
+ )
289
+
290
+ def stem(fn):
291
+ return os.path.splitext(os.path.basename(fn))[0]
292
+
293
+ if match_point_fn:
294
+ match_stem = stem(match_point_fn)
295
+ left = next(
296
+ (f for f in vwip_files if match_stem.startswith(stem(f) + "__")),
297
+ None,
298
+ )
299
+ right_name = (
300
+ match_stem[len(stem(left)) + 2 :]
301
+ if left
302
+ else match_stem.rsplit("__", 1)[-1]
303
+ )
304
+ right = next(
305
+ (f for f in vwip_files if f != left and stem(f).endswith(right_name)),
306
+ None,
307
+ )
308
+ return left, right
309
+
310
+ # No match file (e.g. interest point matching itself failed): fall
311
+ # back to the aligned-image naming. Vwips named after the input
312
+ # images are deliberately NOT picked up here: without a match file
313
+ # there is nothing to say which image is left vs right, nor whether
314
+ # the align transform applies, so guessing could plot the points on
315
+ # the wrong panel or in the wrong coordinate space.
316
+ return (
317
+ glob_file(directory, "*-L.vwip", quiet=True),
318
+ glob_file(directory, "*-R.vwip", quiet=True),
319
+ )
320
+
247
321
 
248
322
  class StereoPlotter(Plotter):
249
323
  """
@@ -374,6 +448,14 @@ class StereoPlotter(Plotter):
374
448
  def match_point_fn(self):
375
449
  return self.files.match_point_fn
376
450
 
451
+ @property
452
+ def left_vwip_fn(self):
453
+ return self.files.left_vwip_fn
454
+
455
+ @property
456
+ def right_vwip_fn(self):
457
+ return self.files.right_vwip_fn
458
+
377
459
  @property
378
460
  def disparity_sub_fn(self):
379
461
  return self.files.disparity_sub_fn
@@ -504,56 +586,136 @@ class StereoPlotter(Plotter):
504
586
  else None
505
587
  )
506
588
 
589
+ def get_vwip_df(self, vwip_fn):
590
+ """
591
+ Read a per-image raw interest point file (``.vwip``) into a DataFrame.
592
+
593
+ These are the interest points ASP detects on each image before
594
+ matching them across the pair; the ``.match`` file keeps only the
595
+ subset that matched. Comparing the two shows whether sparse matches
596
+ come from poor matching or from areas with no interest points at all
597
+ (limited texture, clouds, water).
598
+
599
+ Parameters
600
+ ----------
601
+ vwip_fn : str or None
602
+ Path to the ``.vwip`` file.
603
+
604
+ Returns
605
+ -------
606
+ pandas.DataFrame or None
607
+ DataFrame with columns 'x' and 'y' of the interest point
608
+ coordinates, or None if the file is missing.
609
+
610
+ Notes
611
+ -----
612
+ The file is a single uint64 interest point count followed by the same
613
+ binary interest point records as the ``.match`` file (issue #8).
614
+ """
615
+ if not vwip_fn or not os.path.exists(vwip_fn):
616
+ return None
617
+ with open(vwip_fn, "rb") as vwip_file:
618
+ (size,) = np.frombuffer(vwip_file.read(8), dtype=np.uint64)
619
+ ip = [self.read_ip_record(vwip_file) for _ in range(int(size))]
620
+ return pd.DataFrame({"x": [r[0] for r in ip], "y": [r[1] for r in ip]})
621
+
622
+ @staticmethod
623
+ def _thin_for_display(df):
624
+ """Subsample a point DataFrame down to :data:`MAX_POINTS_DRAWN` rows
625
+ for plotting (see the constant's rationale); returns it unchanged
626
+ when already under the cap. Sampling is seeded (deterministic) and
627
+ random rather than strided, because striding a gridded interest
628
+ point layout produces moiré artifacts."""
629
+ if len(df) <= MAX_POINTS_DRAWN:
630
+ return df
631
+ return df.sample(n=MAX_POINTS_DRAWN, random_state=0).sort_index()
632
+
633
+ @staticmethod
634
+ def _ip_on_aligned_images(files):
635
+ """Whether interest points were found on the aligned L/R images
636
+ rather than on the input images.
637
+
638
+ ASP names the match file after the images interest points were found
639
+ on: when the left name in ``<A>__<B>.match`` is the L image itself
640
+ (e.g. ``run-L__R.match``, as older ASP versions wrote for raw inputs),
641
+ the coordinates are already in aligned space and must not go through
642
+ the alignment matrices again. With no match file at all, the only
643
+ ``.vwip`` files discovered are ``*-L.vwip``/``*-R.vwip`` (see
644
+ :meth:`StereoFiles._find_vwip_files`), which are aligned-space too.
645
+ """
646
+
647
+ def stem(fn):
648
+ return os.path.splitext(os.path.basename(fn))[0]
649
+
650
+ if not files.match_point_fn:
651
+ return True
652
+ if not files.left_image_fn:
653
+ return False
654
+ left_name = stem(files.match_point_fn).split("__")[0]
655
+ return left_name == stem(files.left_image_fn)
656
+
507
657
  def _plot_match_points_figure(self, files, title, save_dir=None, fig_fn=None):
508
658
  """One two-panel match-point figure for a :class:`StereoFiles` or
509
659
  :class:`PairStereoFiles` (duck-typed) set of products."""
510
660
  match_point_df = self.get_match_point_df(files.match_point_fn)
661
+ left_vwip_df = self.get_vwip_df(files.left_vwip_fn)
662
+ right_vwip_df = self.get_vwip_df(files.right_vwip_fn)
511
663
 
512
664
  fig, axa = plt.subplots(1, 2, figsize=(10, 5))
513
665
 
514
- if (
515
- files.left_image_fn
516
- and files.left_image_sub_fn
517
- and files.right_image_sub_fn
518
- and match_point_df is not None
519
- ):
666
+ have_images = (
667
+ files.left_image_fn and files.left_image_sub_fn and files.right_image_sub_fn
668
+ )
669
+ have_points = (
670
+ match_point_df is not None
671
+ or left_vwip_df is not None
672
+ or right_vwip_df is not None
673
+ )
674
+ if have_images and have_points:
675
+ # Both the match file and the .vwip files hold coordinates in the
676
+ # space of the images interest points were found on, so one
677
+ # transform per side maps either onto the sub-sampled images.
520
678
  if self.orthos:
521
679
  full_gsd = Raster(files.left_image_fn).get_gsd()
522
680
  sub_gsd = Raster(files.left_image_sub_fn).get_gsd()
523
681
  rescale_factor = sub_gsd / full_gsd
524
- left_x = match_point_df["x1"] / rescale_factor
525
- left_y = match_point_df["y1"] / rescale_factor
526
- right_x = match_point_df["x2"] / rescale_factor
527
- right_y = match_point_df["y2"] / rescale_factor
528
- else:
529
- if not files.align_left_fn or not files.align_right_fn:
530
- raise FileNotFoundError(
531
- "Alignment matrix files (*-align-{L,R}.txt) not found. "
532
- "These are required to overlay match points on non-mapprojected images."
533
- )
534
682
 
683
+ def to_sub(x, y, side):
684
+ return x / rescale_factor, y / rescale_factor
685
+
686
+ else:
535
687
  full_width = Raster(files.left_image_fn).ds.width
536
688
  sub_width = Raster(files.left_image_sub_fn).ds.width
537
689
  rescale_factor = full_width / sub_width
538
690
 
539
- # Transform match points from original to aligned coordinate space
540
- align_L = np.loadtxt(files.align_left_fn)
541
- align_R = np.loadtxt(files.align_right_fn)
691
+ if self._ip_on_aligned_images(files):
692
+ # Interest points were found on the aligned images
693
+ # themselves (the match file is named for L, e.g.
694
+ # <prefix>-L__R.match, as older ASP wrote for raw
695
+ # inputs), so their coordinates are already in aligned
696
+ # space and only need rescaling.
697
+ def to_sub(x, y, side):
698
+ return x / rescale_factor, y / rescale_factor
542
699
 
543
- n = len(match_point_df)
544
- ones = np.ones(n)
545
-
546
- left_pts = np.vstack([match_point_df["x1"], match_point_df["y1"], ones])
547
- left_aligned = align_L @ left_pts
548
- left_x = left_aligned[0] / rescale_factor
549
- left_y = left_aligned[1] / rescale_factor
700
+ else:
701
+ if not files.align_left_fn or not files.align_right_fn:
702
+ raise FileNotFoundError(
703
+ "Alignment matrix files (*-align-{L,R}.txt) not found. "
704
+ "These are required to overlay match points on non-mapprojected images."
705
+ )
550
706
 
551
- right_pts = np.vstack(
552
- [match_point_df["x2"], match_point_df["y2"], ones]
553
- )
554
- right_aligned = align_R @ right_pts
555
- right_x = right_aligned[0] / rescale_factor
556
- right_y = right_aligned[1] / rescale_factor
707
+ # Transform points from original to aligned coordinate space
708
+ align = {
709
+ "left": np.loadtxt(files.align_left_fn),
710
+ "right": np.loadtxt(files.align_right_fn),
711
+ }
712
+
713
+ def to_sub(x, y, side):
714
+ aligned = align[side] @ np.vstack([x, y, np.ones(len(x))])
715
+ return (
716
+ aligned[0] / rescale_factor,
717
+ aligned[1] / rescale_factor,
718
+ )
557
719
 
558
720
  left_image = Raster(files.left_image_sub_fn).read_array()
559
721
  right_image = Raster(files.right_image_sub_fn).read_array()
@@ -571,28 +733,52 @@ class StereoPlotter(Plotter):
571
733
  add_cbar=False,
572
734
  copyright=True,
573
735
  )
574
- axa[0].set_title(f"Left (n={match_point_df.shape[0]})")
575
- axa[1].set_title("Right")
576
-
577
- axa[0].scatter(
578
- left_x,
579
- left_y,
580
- color="r",
581
- marker="o",
582
- facecolor="none",
583
- s=1,
584
- )
585
- axa[0].set_aspect("equal")
586
-
587
- axa[1].scatter(
588
- right_x,
589
- right_y,
590
- color="r",
591
- marker="o",
592
- facecolor="none",
593
- s=1,
594
- )
595
- axa[1].set_aspect("equal")
736
+
737
+ for ax, side, vwip_df, name in (
738
+ (axa[0], "left", left_vwip_df, "Left"),
739
+ (axa[1], "right", right_vwip_df, "Right"),
740
+ ):
741
+ counts = []
742
+ if vwip_df is not None:
743
+ thinned = self._thin_for_display(vwip_df)
744
+ x, y = to_sub(thinned["x"], thinned["y"], side)
745
+ ax.scatter(
746
+ x,
747
+ y,
748
+ color=INTEREST_POINT_COLOR,
749
+ marker="o",
750
+ facecolor="none",
751
+ s=1,
752
+ label="Interest points",
753
+ )
754
+ counts.append(f"{vwip_df.shape[0]:,} ip")
755
+ if match_point_df is not None:
756
+ thinned = self._thin_for_display(match_point_df)
757
+ x, y = to_sub(
758
+ thinned["x1" if side == "left" else "x2"],
759
+ thinned["y1" if side == "left" else "y2"],
760
+ side,
761
+ )
762
+ ax.scatter(
763
+ x,
764
+ y,
765
+ color=MATCH_POINT_COLOR,
766
+ marker="o",
767
+ facecolor="none",
768
+ s=1,
769
+ label="Matches",
770
+ )
771
+ counts.append(f"{match_point_df.shape[0]:,} matches")
772
+ ax.set_title(
773
+ f"{name} ({', '.join(counts)})" if counts else name,
774
+ fontsize=10,
775
+ )
776
+ ax.set_aspect("equal")
777
+
778
+ if match_point_df is not None and (
779
+ left_vwip_df is not None or right_vwip_df is not None
780
+ ):
781
+ axa[0].legend(loc="best", fontsize=7, markerscale=4)
596
782
  else:
597
783
  self.plot_missing(axa[0])
598
784
  self.plot_missing(axa[1])
@@ -606,8 +792,15 @@ class StereoPlotter(Plotter):
606
792
 
607
793
  Creates a figure with two subplots showing the left and right
608
794
  subsampled images with match points overlaid as small red circles.
609
- For mapprojected scenes, match points are rescaled using the GSD ratio.
610
- For non-mapprojected scenes, match points are transformed from original
795
+ When the per-image raw interest point files (``.vwip``) are present,
796
+ they are underlaid as small blue circles, so sparse matches can be
797
+ traced to either poor matching or areas with no detected interest
798
+ points at all (issue #8); if the match file is missing entirely, the
799
+ raw interest points are still shown on their own. Layers denser than
800
+ :data:`MAX_POINTS_DRAWN` are thinned by seeded random sampling for
801
+ display (the panel titles always report the true counts).
802
+ For mapprojected scenes, points are rescaled using the GSD ratio.
803
+ For non-mapprojected scenes, points are transformed from original
611
804
  to aligned coordinate space using the alignment matrices, then rescaled
612
805
  to the subsampled image dimensions.
613
806
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "asp_plot"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  license = {text = "BSD-3-Clause"}
9
9
  authors = [
10
10
  { name="Ben Purinton", email="purinton@uw.edu" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes