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.
- {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/workflows/claude-code-review.yml +1 -1
- {asp_plot-2.1.0 → asp_plot-2.2.0}/AGENTS.md +2 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/ARCHITECTURE.md +8 -3
- {asp_plot-2.1.0 → asp_plot-2.2.0}/CHANGELOG.md +24 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/PKG-INFO +2 -2
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/csm_analysis.py +146 -27
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/csm_camera.py +103 -26
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/mapproject.py +38 -6
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/report.py +2 -1
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/report_captions.py +1 -1
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/stereo.py +266 -73
- {asp_plot-2.1.0 → asp_plot-2.2.0}/pyproject.toml +1 -1
- {asp_plot-2.1.0 → asp_plot-2.2.0}/.flake8 +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/ISSUE_TEMPLATE/problem-report.yml +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/workflows/claude.yml +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/workflows/release.yml +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/.github/workflows/run-tests.yml +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/.gitignore +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/.pre-commit-config.yaml +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/.readthedocs.yaml +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/CLAUDE.md +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/LICENSE +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/README.md +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/__init__.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/alignment.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/altimetry.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/altimetry_plots.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/altimetry_source.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/asp_log.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/bodies.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/bundle_adjust.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/__init__.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/asp_report.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/csm_camera_plot.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/gallery.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/cli/stereo_geom.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/csm_io.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/gallery.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/icesat2_source.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/planetary_source.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/processing_parameters.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/report_pipeline.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/scenes.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/selections.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/__init__.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/aster.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/base.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/dimap.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/dimap_v1.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/rpc.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/sensors/worldview.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/stereo_geometry.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/stereopair_metadata_parser.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/asp_plot/utils.py +0 -0
- {asp_plot-2.1.0 → asp_plot-2.2.0}/conda-forge-recipe/meta.yaml +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: asp_plot
|
|
3
|
-
Version: 2.
|
|
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
|
-
``
|
|
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
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
148
|
-
np.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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 =
|
|
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
|
-
|
|
515
|
-
files.left_image_fn
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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
|
|
|
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
|
|
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
|
|
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
|