asp-plot 3.2.0__tar.gz → 3.4.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 (61) hide show
  1. {asp_plot-3.2.0 → asp_plot-3.4.0}/.github/workflows/release.yml +3 -1
  2. {asp_plot-3.2.0 → asp_plot-3.4.0}/.gitignore +7 -1
  3. {asp_plot-3.2.0 → asp_plot-3.4.0}/.readthedocs.yaml +2 -2
  4. {asp_plot-3.2.0 → asp_plot-3.4.0}/AGENTS.md +50 -3
  5. {asp_plot-3.2.0 → asp_plot-3.4.0}/ARCHITECTURE.md +23 -8
  6. {asp_plot-3.2.0 → asp_plot-3.4.0}/CHANGELOG.md +47 -0
  7. {asp_plot-3.2.0 → asp_plot-3.4.0}/PKG-INFO +1 -1
  8. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/alignment.py +13 -2
  9. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/altimetry_source.py +30 -6
  10. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/cli/asp_report.py +11 -0
  11. asp_plot-3.4.0/asp_plot/cli/dem_benchmark.py +181 -0
  12. asp_plot-3.4.0/asp_plot/dem_benchmark.py +1376 -0
  13. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/icesat2_source.py +77 -39
  14. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/planetary_source.py +11 -2
  15. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/report.py +72 -6
  16. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/report_captions.py +2 -2
  17. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/report_pipeline.py +5 -0
  18. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/stereo.py +110 -39
  19. {asp_plot-3.2.0 → asp_plot-3.4.0}/conda-forge-recipe/meta.yaml +2 -0
  20. {asp_plot-3.2.0 → asp_plot-3.4.0}/pyproject.toml +2 -1
  21. {asp_plot-3.2.0 → asp_plot-3.4.0}/.flake8 +0 -0
  22. {asp_plot-3.2.0 → asp_plot-3.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  23. {asp_plot-3.2.0 → asp_plot-3.4.0}/.github/ISSUE_TEMPLATE/problem-report.yml +0 -0
  24. {asp_plot-3.2.0 → asp_plot-3.4.0}/.github/workflows/claude-code-review.yml +0 -0
  25. {asp_plot-3.2.0 → asp_plot-3.4.0}/.github/workflows/claude.yml +0 -0
  26. {asp_plot-3.2.0 → asp_plot-3.4.0}/.github/workflows/run-tests.yml +0 -0
  27. {asp_plot-3.2.0 → asp_plot-3.4.0}/.pre-commit-config.yaml +0 -0
  28. {asp_plot-3.2.0 → asp_plot-3.4.0}/CLAUDE.md +0 -0
  29. {asp_plot-3.2.0 → asp_plot-3.4.0}/LICENSE +0 -0
  30. {asp_plot-3.2.0 → asp_plot-3.4.0}/README.md +0 -0
  31. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/__init__.py +0 -0
  32. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/altimetry.py +0 -0
  33. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/altimetry_plots.py +0 -0
  34. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/asp_log.py +0 -0
  35. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/bodies.py +0 -0
  36. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/bundle_adjust.py +0 -0
  37. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/cli/__init__.py +0 -0
  38. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/cli/bundle_adjust_cameras.py +0 -0
  39. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/cli/csm_camera_plot.py +0 -0
  40. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/cli/gallery.py +0 -0
  41. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
  42. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/cli/stereo_geom.py +0 -0
  43. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/csm_analysis.py +0 -0
  44. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/csm_camera.py +0 -0
  45. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/csm_io.py +0 -0
  46. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/gallery.py +0 -0
  47. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/mapproject.py +0 -0
  48. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/processing_parameters.py +0 -0
  49. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/scenes.py +0 -0
  50. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/selections.py +0 -0
  51. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/sensors/__init__.py +0 -0
  52. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/sensors/aster.py +0 -0
  53. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/sensors/base.py +0 -0
  54. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/sensors/dimap.py +0 -0
  55. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/sensors/dimap_v1.py +0 -0
  56. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/sensors/rpc.py +0 -0
  57. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/sensors/worldview.py +0 -0
  58. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/stereo_geometry.py +0 -0
  59. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/stereopair_metadata_parser.py +0 -0
  60. {asp_plot-3.2.0 → asp_plot-3.4.0}/asp_plot/utils.py +0 -0
  61. {asp_plot-3.2.0 → asp_plot-3.4.0}/environment.yml +0 -0
@@ -37,7 +37,9 @@ jobs:
37
37
  - name: Get previous tag
38
38
  id: prev_tag
39
39
  run: |
40
- PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
40
+ # --match 'v*' so the dated reports-<date> tags (issue #201) are not
41
+ # mistaken for the previous version and compared against in the notes.
42
+ PREV_TAG=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "")
41
43
  echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
42
44
 
43
45
  - name: Check if tag exists
@@ -146,6 +146,12 @@ reports/regenerate_reports.sh
146
146
  # Figure-selections sidecars written next to each report by `asp_report`. These are
147
147
  # regeneration byproducts that hardcode absolute local paths (dem_filename,
148
148
  # parquet_cache), so they are not committed; a sanitized example is documented in
149
- # docs/cli/asp_report.md instead.
149
+ # docs/cli/asp_report.md instead. They are attached to each reports-<date>
150
+ # release alongside the PDFs they generated.
150
151
  reports/*_figure_selections.yml
152
+ # The example reports themselves. PDFs do not delta-compress, so every
153
+ # regeneration became a permanent new blob -- 7 files, 116 objects, 846 MB of
154
+ # history. They are published as GitHub Release assets instead, and the docs
155
+ # build fetches them (docs/fetch_example_reports.sh). See issue #201.
156
+ reports/*.pdf
151
157
  *.csv
@@ -15,8 +15,8 @@ build:
15
15
  - cp notebooks/Mars_MRO/*.ipynb docs/examples/notebooks/
16
16
  - mkdir -p docs/_extra/examples/figures
17
17
  - cp notebooks/figures/* docs/_extra/examples/figures/
18
- - mkdir -p docs/_static/reports
19
- - cp reports/*.pdf docs/_static/reports/
18
+ # Reports are GitHub Release assets, not committed to git (issue #201).
19
+ - bash docs/fetch_example_reports.sh
20
20
 
21
21
  sphinx:
22
22
  configuration: docs/conf.py
@@ -30,14 +30,58 @@ Flake8 config is in `.flake8` (extends ignore: E203, E701); pre-commit further i
30
30
  To build the docs locally (Sphinx + MyST; hosted on ReadTheDocs, auto-built on push to `main`):
31
31
 
32
32
  ```bash
33
- # One-time: copy notebooks, reports, and figures for local preview
33
+ # One-time: copy notebooks and figures, fetch the reports, for local preview
34
34
  mkdir -p docs/examples/notebooks && cp notebooks/**/*.ipynb docs/examples/notebooks/
35
- mkdir -p docs/_static/reports && cp reports/*.pdf docs/_static/reports/
36
35
  mkdir -p docs/_extra/examples/figures && cp notebooks/figures/* docs/_extra/examples/figures/
36
+ bash docs/fetch_example_reports.sh # downloads the reports from their Release
37
37
 
38
38
  sphinx-autobuild docs docs/_build/html --open-browser # or sphinx-build for a one-off
39
39
  ```
40
40
 
41
+ ## Example Reports Are Release Assets, Not Committed Files
42
+
43
+ `reports/*.pdf` is gitignored. PDFs do not delta-compress, so every regeneration
44
+ was a wholly new permanent blob — seven report files had become 116 immortal
45
+ objects and 846 MB of history, most of a 1.8 GB clone (issue #201). They are
46
+ published as GitHub Release assets instead, on a dated `reports-<date>` tag that
47
+ is bumped only when the reports change — deliberately *not* the per-version
48
+ releases, since `release.yml` creates one per `pyproject.toml` bump and the
49
+ reports do not change per version.
50
+
51
+ To publish a regenerated set:
52
+
53
+ ```bash
54
+ bash reports/regenerate_reports.sh # gitignored; needs ~/Desktop/asp-plot-examples
55
+ gh release create reports-$(date +%F) \
56
+ reports/*.pdf reports/*_figure_selections.yml reports/regenerate_reports.sh \
57
+ --target main --latest=false \
58
+ --title "Example reports, $(date +%F)" --notes "What changed and why."
59
+ # then bump REPORTS_RELEASE in docs/fetch_example_reports.sh and commit that one line
60
+ ```
61
+
62
+ Four things matter here:
63
+
64
+ - **Publish the release before pushing the commit** that points at it. The docs
65
+ build uses `curl -f`, so a tag that does not exist yet fails the build.
66
+ - **`--latest=false`**, or the reports release takes the "Latest" badge from the
67
+ current version release.
68
+ - **Attach the `*_figure_selections.yml` sidecars too.** They are gitignored (they
69
+ hardcode absolute local paths) but they are what makes regeneration
70
+ figure-stable and offline (issue #121), so keeping them with the PDFs they
71
+ produced makes a release self-describing.
72
+ - **Attach `regenerate_reports.sh` as well, for the same reason and one more.**
73
+ It is gitignored too, which means it is in no clone *and in no backup* — a
74
+ mirror of this repository contains zero objects for it, because git never
75
+ tracked it. The release is therefore its only durable copy, and a release that
76
+ carries the reports, the selections that shaped them, and the commands that
77
+ produced them can be reproduced by someone who has the example datasets and
78
+ nothing else.
79
+
80
+ Always regenerate with `--reuse-selections` so pages stay figure-for-figure
81
+ comparable and no SlideRule request is made. Reports are capped at 200 effective
82
+ dpi at their placed width (`asp_report --figure-max-dpi`, `0` disables), which is
83
+ what keeps the assets small.
84
+
41
85
  ## Gotchas
42
86
 
43
87
  - **ASP tools must be on PATH** for the workflows that wrap them: `stereo`/`parallel_stereo`, `bundle_adjust`, `point2dem`, `pc_align`, `geodiff`, `dg_mosaic` (called via `run_subprocess_command()`). **Append** the ASP bin directory to PATH rather than prepending: the ASP release bundles its own `python`, which would shadow your environment's interpreter and break imports.
@@ -45,10 +89,13 @@ sphinx-autobuild docs docs/_build/html --open-browser # or sphinx-build for a
45
89
  - **Mars altimetry needs the `*_pts_csv.csv`** (with `PLANET_RAD`), never the `*_topo_csv.csv`: MOLA TOPOGRAPHY is referenced to the oblate areoid while ASP DEMs use the spherical IAU datum — a latitude-dependent offset up to ~10 km that pc_align cannot remove. The loader rejects the topo file with an explanatory error.
46
90
  - **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
91
  - **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
- - **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.
92
+ - **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 CLIs (`stereo_geom`, `csm_camera_plot`, `bundle_adjust_cameras`, `request_planetary_altimetry`, `gallery`, `dem_benchmark`) are unprefixed.
49
93
  - **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
94
  - **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.
51
95
  - **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.
96
+ - **The altimetry outlier cut is a 30-NMAD gate plus 3σ; both values are deliberate.** A whole ICESat-2 pass can be cloud (over UCSD one pass was a marine-layer return 150–210 m above ground, 21 % of the sample), which defeats a plain mean ± 3σ cut by inflating σ; the gate removes it. Don't "simplify" the cut to median ± 3 NMAD: on the heavy-tailed residuals a DSM has against ICESat-2 in a city that removes a fifth of the real points and lowers every reported NMAD by a third (`_outlier_mask` in `altimetry_source.py`; the case is described in the benchmark notebook's UCSD section). If a benchmark or report shows an RMSE far above its NMAD, look for a cloud pass in the residual map before touching the DEM.
97
+ - **`asp_plot.sensors.rpc.read_rpc()` refuses any raster that carries a CRS**, on purpose: an RPC describes the raw image grid, and ASP's `mapproject` copies RPC metadata onto its outputs. However, GDAL sometimes writes a lat/lon geotransform from a NITF's corner coordinates when converting it (one of five CORE3D scenes came out that way), and the reader then calls a raw scene "already map-projected". Strip it with `gdal_edit.py -unsetgt -a_srs "" scene.tif`.
98
+ - **SpaceNet CORE3D NITF scenes are JPEG2000, and the ASP 3.8.0-alpha build ships GDAL's JP2OpenJPEG driver as a plugin it never loads**: `gdalinfo` and rasterio both fail with *No JPEG2000 capable driver is available*. `GDAL_DRIVER_PATH=<ASP root>/lib` for one `gdal_translate` to GeoTIFF is enough; the older 3.7.0 build had the driver built in, which is why earlier UCSD runs never hit this.
52
99
  - **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.
53
100
 
54
101
  ## External Data Sources
@@ -109,7 +109,7 @@ The package is organized by functionality, with each module focused on a specifi
109
109
  - Detects map-projection status via `Raster.transform` check
110
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
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
112
- - **Plain-text match files** (issue #147): ASP >= 3.7.0 writes `<A>__<B>.txt` (one `x1 y1 unc1 x2 y2 unc2` line per match) *instead of* `.match` when run with `--matches-as-txt`, so `StereoFiles._find_match_file()` globs both `*.match` and `*__*.txt` (the `__` anchor plus a first-row check — six numeric fields, `_opens_with_match_row()` — keeps logs and alignment matrices out even when the run prefix itself contains `__`), preferring binary when both coexist. `get_match_point_df()` detects the format from the bytes (`_is_text_match_file()`: the binary header's uint64 counts contain NULs, text never does — so a renamed file still parses) and routes text through `_read_text_match_file()` — read directly, uncertainties dropped, never via the `.csv` cache a binary conversion leaves behind — while the binary path keeps its CSV conversion. `.vwip` files stay binary-only: stereo never writes text ones (`ipfind --binary-to-txt` is a manual conversion)
112
+ - **Plain-text match files** (issue #147): ASP >= 3.7.0 writes `<A>__<B>.txt` (one `x1 y1 unc1 x2 y2 unc2` line per match) *instead of* `.match` when run with `--matches-as-txt`, so `StereoFiles._find_match_file()` globs both `*.match` and `*__*.txt` (the `__` anchor plus a first-row check — six numeric fields, `_opens_with_match_row()` — keeps logs and alignment matrices out even when the run prefix itself contains `__`), preferring binary when both coexist. the module-level `read_match_file()` (which `get_match_point_df()` delegates to, so a match file reads without a stereo directory) detects the format from the bytes (`_is_text_match_file()`: the binary header's uint64 counts contain NULs, text never does — so a renamed file still parses) and routes text through `_read_text_match_file()` — read directly, uncertainties dropped, never via the `.csv` cache a binary conversion leaves behind — while the binary path keeps its CSV conversion. `.vwip` files stay binary-only: stereo never writes text ones (`ipfind --binary-to-txt` is a manual conversion)
113
113
  - **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
114
114
  - 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()`
115
115
  - `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.
@@ -154,21 +154,21 @@ The package is organized by functionality, with each module focused on a specifi
154
154
  - New dependency from this subsystem: `pyyaml` (request-metadata YAML); `sliderule>=5.3.0` pinned
155
155
 
156
156
  **`altimetry_source.py`** - `AltimetrySource` base (issue #140)
157
- - Shared machinery lifted out of the ICESat-2 and planetary sources so neither re-implements it: `_interp_dem_at_points(dem, points)` (bilinear DEM sampling; returns sampled values **and** the points reprojected into the DEM/working CRS), `_open_dem(dem_fn)`, `_std_outlier_mask(dh, n_sigma)` (n-σ std-from-mean mask, returns `None` on empty/all-NaN or zero spread to signal "do not filter"), and `_write_csv_to_directory(df, filename)` (roots pc_align CSVs at `self.alt.directory`)
157
+ - Shared machinery lifted out of the ICESat-2 and planetary sources so neither re-implements it: `_interp_dem_at_points(dem, points)` (bilinear DEM sampling; returns sampled values **and** the points reprojected into the DEM/working CRS), `_open_dem(dem_fn)`, `_outlier_mask(dh, n_sigma)` (two cuts: gross outliers more than `GROSS_OUTLIER_NMAD` = 30 NMAD from the median are dropped first, then the n-σ std-from-mean cut runs on what survives; returns `None` on empty/all-NaN or zero spread to signal "do not filter". The gate exists because one whole ICESat-2 pass over UCSD was a marine-layer cloud return 150–210 m up — a fifth of the sample — which inflates σ to 70 m so a plain 3σ cut removes nothing; a pure median/NMAD cut was rejected because on the heavy-tailed DSM-vs-ICESat-2 residuals of a city it strips a fifth of the *real* points and lowers every NMAD by a third), and `_write_csv_to_directory(df, filename)` (roots pc_align CSVs at `self.alt.directory`)
158
158
 
159
159
  **`icesat2_source.py`** - `Icesat2Source(AltimetrySource)` (Earth / ICESat-2)
160
160
  - Requests/processes ICESat-2 ATL06-SR via the SlideRule x-series API (`sliderule_api.run("atl03x")`) with lazy init (`_ensure_sliderule()` — connects only when ICESat-2 methods run). SlideRule logging silenced (`verbose=False`, WARNING, explicit filter on `sliderule.session`)
161
161
  - `request_atl06sr_multi_processing()` decomposed (#140) into a short request/cache/ingest loop over helpers: `_print_time_filter_summary`, `_build_level_parms`, `_load_cached_atl06sr`, `_params_match_cache`, `_request_atl06sr_level`, `_ingest_atl06sr`. Parquet cache saved to `self.alt.directory` (not CWD); the param comparison strips the `output` key (SlideRule injects a random temp path into `parms` during `run()`) and only the comparison is wrapped in `try/except` so SlideRule API errors propagate
162
162
  - Server-side time filtering via `_resolve_time_range(...)`: default `"all"` = full mission (2018-10-14 → today UTC midnight); `"buffered"` cascades explicit `t0`/`t1` > `scene_date` ± `time_buffer_days` > XML metadata ± buffer > `"all"`. `t1` truncated to midnight UTC for stable caching
163
163
  - ESA WorldCover sampling: `sample_esa_worldcover()` / `_sample_worldcover_into_gdf()` read 10m values directly from public AWS S3 COGs (`esa-worldcover.s3.amazonaws.com/v200/2021/map/`) via `rasterio` vsicurl, batched per tile, persisted into the parquet cache (much faster than SlideRule's server-side `samples`). `_worldcover_tile_url(lat, lon)` maps a coord to the 3×3° tile URL (flat layout). Anonymous reads (unsigned AWS session) so SSO configs don't crash
164
- - `filter_outliers(n_sigma=3)`: drops dh beyond `n_sigma × std` from the mean (true std, not NMAD); called automatically by `atl06sr_to_dem_dh()`; pass `None` to skip
164
+ - `filter_outliers(n_sigma=3)`: drops dh beyond `n_sigma × std` from the mean (true std, not NMAD) after the 30-NMAD gross-outlier gate (`_outlier_mask`); called automatically by `atl06sr_to_dem_dh()`; pass `None` to skip. When an aligned DEM is set, `icesat_minus_aligned_dem` is blanked wherever `icesat_minus_dem` is NaN (same in `planetary_to_dem_dh()`): `pc_align`'s translation moves the DEM's holes, so a point that sampled NaN before alignment — and therefore passed the cut untested — must not enter the aligned residuals when it lands on data afterwards
165
165
  - `atl06sr_to_dem_dh()`: opens the DEM via `self._open_dem`, samples via `self._interp_dem_at_points`, computes the dh column. Track/segment selection: `_select_best_track()` (most valid dh points), `_find_best_worst_segments()` / `_segment_dict()` / `_resolve_best_track()`
166
166
  - **Run-to-run reuse (issue #121)**: `load_atl06sr_from_parquet()` replays the *exact* prior points (bypassing SlideRule), `_restore_request_metadata_from_parquet()` restores `t0`/`t1` for plot titles, `get_altimetry_selections()` returns request params / parquet paths / track / segments. Segments pinned by **absolute `x_atc`** (not km-from-track-start) so they survive a track-start shift when 3σ filtering a different DEM drops a different first point
167
167
  - `_extract_scalar()` handles array-valued cells from the x-series API / parquet round-trip; module constant `ICESAT2_MISSION_START`
168
168
 
169
169
  **`planetary_source.py`** - `PlanetarySource(AltimetrySource)` + `LolaSource` / `MolaSource` (issue #140)
170
170
  - Two-step workflow: (1) submit async query via `request_planetary_altimetry` CLI → email with download link, (2) load the CSV. The body subclass is selected by the coordinator at construction, so loading no longer re-detects the body per call
171
- - `PlanetarySource` (body-agnostic base): the dh + export half — `_load_planetary_csv_common()`, `_build_planetary_gdf()`, `_find_csv_column()`, `_sample_dem_at_planetary_points()` (uses `self._open_dem` / `self._interp_dem_at_points`), `planetary_to_dem_dh()` (uses `self._std_outlier_mask`; when `aligned_dem_fn` is set also samples the aligned DEM so pre/post plots share one sample), `to_csv_for_pc_align_planetary()` (writes `lon, lat, radius_m`). Its `load_planetary_csv()` raises and redirects to ICESat-2 (the Earth case)
171
+ - `PlanetarySource` (body-agnostic base): the dh + export half — `_load_planetary_csv_common()`, `_build_planetary_gdf()`, `_find_csv_column()`, `_sample_dem_at_planetary_points()` (uses `self._open_dem` / `self._interp_dem_at_points`), `planetary_to_dem_dh()` (uses `self._outlier_mask`; when `aligned_dem_fn` is set also samples the aligned DEM so pre/post plots share one sample), `to_csv_for_pc_align_planetary()` (writes `lon, lat, radius_m`). Its `load_planetary_csv()` raises and redirects to ICESat-2 (the Earth case)
172
172
  - `LolaSource` (Moon, `instrument = "LOLA"`): prefers `Pt_Radius` (LOLA RDR Point-per-Row CSV, in **km** — auto-detected by magnitude < 10 000, converted to m), falls back to `Topography` (simple-topo CSV, meters). Moon ≈ spherical (~1.4 km variation) so the fallback is fine
173
173
  - `MolaSource` (Mars, `instrument = "MOLA"`): reads `PLANET_RAD` from the ODE GDS `*_pts_csv.csv`, `height = PLANET_RAD - 3_396_190`. The `*_topo_csv.csv` (TOPOGRAPHY only) is **rejected** with an explanatory error — TOPOGRAPHY is referenced to the **oblate** MOLA areoid while ASP DEMs use the **spherical** IAU 2000 datum, a latitude-dependent offset up to ~10 km that pc_align can't remove
174
174
  - Both loaders store `height` (above the IAU sphere; plot labels + dh stats) and `radius_m` (absolute planetary radius; pc_align) on `planetary_points`
@@ -215,6 +215,15 @@ The package is organized by functionality, with each module focused on a specifi
215
215
  - Key method: `plot_gallery()`; static helper `_grid_shape(n, aspect)` picks the most square-in-display grid
216
216
  - Not wired into the main `asp_report` PDF report (standalone class + `gallery` CLI)
217
217
 
218
+ **`dem_benchmark.py`** - `DEMBenchmark` class (issue #169)
219
+ - Scores *many* DEMs against *one* altimetry sample so scene combinations, processing flows (joint MVS vs. pairwise + `dem_mosaic`) and parameter sweeps are compared on the same points. Replaces the ad hoc loop in the Atlanta MVS notebook: one `Altimetry` per DEM (kept in `self.altimetry[label]` for follow-up per-DEM figures), the same parquet replayed via `load_atl06sr_from_parquet` (or `load_planetary_csv`), the report's WorldCover water filter (`filter_out`, a group name or a list such as `["water", "trees"]` for stable terrain; validated against `icesat2_source.WORLDCOVER_GROUPS`, CLI `--filter-out`, repeatable) and outlier cut (30-NMAD gate + 3σ)
220
+ - `run()` → `stats_df`, one row per DEM, columns `STATS_COLUMNS`: coverage (`valid_pct` / `valid_area_km2`) inside a common AOI (default the **intersection** of all DEM extents in the first DEM's CRS, so differing crop windows compare fairly; falls back to own extents with a warning when footprints don't all overlap), IntersectionErr median/NMAD from the `point2dem` sibling raster (`intersection_error_path()`; NaN for mosaics), altimetry-minus-DEM n/median/NMAD/RMSE before and after a per-DEM `pc_align --compute-translation-only` (translation + N-E-D shifts from `Alignment.pc_align_report`), and optional DEM-minus-reference stats via `Raster.compute_difference`
221
+ - **Shared point set and paired block bootstrap** (issue #199). Each DEM keeps different points (voids, per-DEM outlier cut), and scoring on them changes the ranking, so after the per-DEM loop `_shared_stats()` intersects the point ids every DEM kept (`shared_ids`) and rescores all of them there (`*_shared_*` columns; the figure and the sort use these, the per-DEM `dh_*` columns stay as secondary numbers). Points are identified across the per-DEM `Altimetry` objects by a positional `benchmark_point_id` column stamped right after the parquet/CSV load (`_tag_points` / `_tag_planetary_points`; the tables share a non-unique time index, and every DEM replays the same file through the same ingest so row order is identical). `paired_block_bootstrap()` then resamples whole blocks — ICESat-2 beam tracks `(rgt, cycle, spot)`, MOLA `ORBIT`; LOLA has no track id and falls back to points with a warning — and evaluates every DEM on the same replicate, giving `median_ci_*` / `nmad_ci_*` (95 %), `nmad_vs_best_ci_*` (paired difference to the lowest-NMAD DEM; includes 0 = not separable) and `p_best` at the ranked stage (`bootstrap["stage"]`: aligned when any DEM has it). Track blocks, not points, because ATL06-SR segments overlap (40 m posted at 20 m) and residuals cluster with terrain; paired differences, not overlapping per-DEM intervals, because DEMs on the same points share most of their sampling variation
222
+ - Window reads (`_window_for_bounds` / `_read_window`) are downsampled past `MAX_WINDOW_PIXELS` so a 1 m 20k×20k DEM stays at ~16 M samples (valid fraction becomes a subsample estimate; area scales from it)
223
+ - pc_align products and the translated DEM go under `<directory>/dem_benchmark/<label>/` (via `Alignment.apply_dem_translation(output_fn=...)`), never next to the candidate DEMs; an existing `*-transform.txt` there is reused (a re-run makes no pc_align call and works offline); a missing `pc_align` binary (`FileNotFoundError`) degrades to pre-alignment scoring with one warning
224
+ - Figures: `summary_plot()` (rows = DEMs sorted best-first by post-alignment shared-point NMAD; panels coverage / dh median / dh NMAD as before→after dumbbells (IntersectionErr is table-only: across geometries it ranks opposite to accuracy, #199) with the bootstrap interval as an error bar on the ranked marker and printed in brackets, `best` / `≈ best` labels in the NMAD panel; the NMAD panel is anchored at zero and its markers coincide by construction for a translation-only alignment) and `histogram_plot()` (overlaid residual outlines on the shared points). `label_from_dem_path()` / `parse_dem_specs()` back the CLI's `LABEL=PATH` specs (an ASP `run-DEM.tif` is labelled by its folder)
225
+ - Not wired into the `asp_report` PDF (standalone class + `dem_benchmark` CLI)
226
+
218
227
  ## CLI Tools
219
228
 
220
229
  All CLI tools are in `asp_plot/cli/` and use Click for argument parsing:
@@ -277,6 +286,11 @@ All CLI tools are in `asp_plot/cli/` and use Click for argument parsing:
277
286
  - `--no-hillshade`, `--cmap`, `--downsample`, `--max-filesize-mb`, `--title`, `--output-directory/--output-filename`
278
287
  - Saves `<dirname>_gallery.png` into the input directory by default
279
288
 
289
+ **`dem_benchmark.py`** - Multi-DEM altimetry benchmark tool (`dem_benchmark` command)
290
+ - Wrapper for `DEMBenchmark`; positional `DEMS` as paths or `LABEL=PATH`
291
+ - `--parquet` (Earth, the ATL06-SR cache) or `--altimetry-csv` (Moon/Mars); `--directory` (where `dem_benchmark/<label>/` products go), `--reference`, `--no-pc-align`, `--own-extent`, `--n-bootstrap` (0 skips), `--title`, `--output-directory/--output-filename`
292
+ - Writes the summary figure, a `_histogram.png` twin, and the stats table as `.csv` with the same stem, and prints the headline (shared-point) columns of the table
293
+
280
294
  ## Documentation Website
281
295
 
282
296
  Documentation is hosted at **https://asp-plot.readthedocs.io** and built with Sphinx + MyST Markdown + sphinx-book-theme. ReadTheDocs builds automatically on push to `main`.
@@ -295,7 +309,7 @@ docs/
295
309
  conf.py # Sphinx configuration
296
310
  index.md # Landing page with sphinx-design cards
297
311
  installation.md # conda/pip/source install
298
- cli/ # CLI tool docs (asp_report, stereo_geom, csm_camera_plot, request_planetary_altimetry, gallery)
312
+ cli/ # CLI tool docs (asp_report, stereo_geom, csm_camera_plot, bundle_adjust_cameras, request_planetary_altimetry, gallery, dem_benchmark)
299
313
  examples/
300
314
  index.md # Notebook gallery with cards by sensor
301
315
  reports.md # PDF reports embedded as iframes
@@ -303,7 +317,7 @@ docs/
303
317
  figures/ # Committed doc figures (e.g. example_gallery.png); referenced from .md via ../figures/
304
318
  contributing.md # Dev setup, testing, release process
305
319
  changelog.md # Includes top-level CHANGELOG.md via MyST include
306
- _static/reports/ # .gitignored — copied from reports/ during build
320
+ _static/reports/ # .gitignored — fetched from the reports-<date> Release during build
307
321
  _extra/examples/figures/ # .gitignored — copied from notebooks/figures/ during build
308
322
  _templates/footer.html # RTD ethical ads placement in footer
309
323
  requirements.txt # Docs-only pip dependencies for RTD
@@ -311,7 +325,8 @@ docs/
311
325
 
312
326
  ### Key Design Decisions
313
327
 
314
- - **Notebooks and reports stay at top level** (`notebooks/`, `reports/`). RTD's `pre_build` jobs in `.readthedocs.yaml` copy them into the docs tree. The `docs/examples/notebooks/`, `docs/_static/reports/`, and `docs/_extra/` directories are `.gitignore`d.
328
+ - **Notebooks stay at top level** (`notebooks/`) and RTD's `pre_build` jobs in `.readthedocs.yaml` copy them into the docs tree. The `docs/examples/notebooks/`, `docs/_static/reports/`, and `docs/_extra/` directories are `.gitignore`d.
329
+ - **Reports are not in git at all.** `reports/*.pdf` is gitignored and published as assets on a dated `reports-<date>` GitHub Release; `docs/fetch_example_reports.sh` downloads them into `_static/reports/` during the build, replacing what used to be a `cp` (issue #201). They must be self-hosted rather than linked directly because GitHub serves release assets as `content-disposition: attachment`, which an `<iframe>` cannot render. The script pins one `REPORTS_RELEASE` tag, so a docs build of an older commit keeps fetching the reports of its era.
315
330
  - **sphinx-autoapi** generates API reference from static analysis — no GDAL/rasterio needed on RTD. This is why RTD installs only `docs/requirements.txt` instead of the full package.
316
331
  - **`html_extra_path`** is used to serve `notebooks/figures/` at the correct relative path for notebook `<img src>` references.
317
332
  - **`docs/` is excluded from sdist** in `pyproject.toml` so docs never ship in the PyPI/conda package.
@@ -351,7 +366,7 @@ Docs dependencies are in `pyproject.toml` under `[project.optional-dependencies]
351
366
  ## Example Notebooks
352
367
 
353
368
  Example notebooks demonstrating modular usage are organized by sensor type:
354
- - `notebooks/WorldView/` - WorldView examples (Earth-based). `worldview_spacenet_ucsd_stereo.ipynb` is the classic two-scene pair example; `worldview_utqiagvik_stereo.ipynb` (excluded from the docs build) and `worldview_uyuni_jitter_plots.ipynb` cover comprehensive modular usage and jitter plots; the two `*_scene_selection.ipynb` notebooks pick which scenes to process with `stereo_geom` before any ASP run. The Atlanta example (`worldview_spacenet_atlanta_mvs.ipynb`) is a 3-scene same-pass multi-view run (`parallel_stereo --alignment-method affineepipolar` on the `wv_correct`ed `*_corr.tif` images, nadir13 reference) compared against the ASP-docs-recommended alternative: the three pairwise runs merged with `dem_mosaic` (coverage, DEM difference, ICESat-2 residuals). Two crop-window tricks keep the ROIs matched: the reference crop window in mapprojected/ground coordinates converts to image pixels via GDAL's inverse RPC transform on the *original* `.tif` (the `wv_correct` `_corr.tif` carries no RPC metadata) — `gdal.Transformer(["METHOD=RPC", "RPC_HEIGHT=<m>"])` + `TransformPoint(1, lon, lat, h)` on the corners; and the `--left-image-crop-win` for a pair whose left image is *not* the reference is derived from the `bundle_adjust` clean match points that fall inside the reference crop window (no RPCs needed at all).
369
+ - `notebooks/WorldView/` - WorldView examples (Earth-based). `worldview_spacenet_ucsd_stereo.ipynb` is the classic two-scene pair example; `worldview_utqiagvik_stereo.ipynb` (excluded from the docs build) and `worldview_uyuni_jitter_plots.ipynb` cover comprehensive modular usage and jitter plots; the two `*_scene_selection.ipynb` notebooks pick which scenes to process with `stereo_geom` before any ASP run. **`worldview_spacenet_benchmark.ipynb` is the scene-combination benchmark (issue #169)**: with `DEMBenchmark` on one ICESat-2 sample per site it scores every combination and flow — at Atlanta 21 DEMs (all ten pairs of the five same-pass scenes, six `dem_mosaic` blends incl. median / convergence-weighted / `VerticalStdDev`-weighted, six multi-view runs incl. two references) then at UCSD 17 DEMs (five multi-date WorldView-3 scenes over a steep crop of Mount Soledad: three multi-view references, ten pairs, four blends) (both sets of runs come from local driver scripts that are not in the repo; the notebook re-scores whatever is on disk). Crop and scene selection for UCSD are done in the notebook from metadata staged next to the data (`ucsd_mvs/selection/`). Its findings, in its two takeaways sections: at the same-pass flat site single-pair accuracy is set by convergence angle; at the steep multi-date site the point estimates depend more on which scene a pair contains than on its geometry, but a paired track bootstrap (#199) separates only the worst DEM from the rest, so those rules are suggested rather than established; at both sites the multi-view reference scene has the largest effect. The Atlanta example (`worldview_spacenet_atlanta_mvs.ipynb`) is a 3-scene same-pass multi-view run (`parallel_stereo --alignment-method affineepipolar` on the `wv_correct`ed `*_corr.tif` images, nadir13 reference) compared against the ASP-docs-recommended alternative: the three pairwise runs merged with `dem_mosaic` (coverage, DEM difference, ICESat-2 residuals). Two crop-window methods keep the ROIs matched: the reference crop window in mapprojected/ground coordinates converts to image pixels via GDAL's inverse RPC transform on the *original* `.tif` (the `wv_correct` `_corr.tif` carries no RPC metadata) — `gdal.Transformer(["METHOD=RPC", "RPC_HEIGHT=<m>"])` + `TransformPoint(1, lon, lat, h)` on the corners; and the `--left-image-crop-win` for a pair whose left image is *not* the reference is derived from the `bundle_adjust` clean match points that fall inside the reference crop window (no RPCs needed at all).
355
370
  - `notebooks/Pleiades/` - Airbus Pléiades Neo tri-stereo example over Marseille (Earth-based): DIMAP stereo-geometry analysis, bundle adjustment of all three scenes, and a 3-image multi-view stereo run (nadir reference, `--left-image-crop-win` derived from the DIMAP `Dataset_Extent` vertices; the ASP docs discourage MVS in favor of pairwise + `dem_mosaic`, which the notebook cites)
356
371
  - `notebooks/ASTER/` - ASTER examples with map-projection and jitter correction (Earth-based)
357
372
  - `notebooks/LRO_NAC/` - Lunar Reconnaissance Orbiter Narrow Angle Camera examples (Lunar)
@@ -5,6 +5,53 @@ 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
+ ## [3.4.0] - 2026-09-29
9
+
10
+ In this release:
11
+
12
+ - The DEM benchmark compares DEMs on the same altimetry points, reports which differences between them are distinguishable, and can drop more than one land-cover group from the reference sample ([#199](https://github.com/uw-cryo/asp_plot/issues/199)).
13
+ - The report states what the intersection error measures ([#199](https://github.com/uw-cryo/asp_plot/issues/199)).
14
+ - The example reports moved out of git and into release assets, and figures embedded in a report are capped at 200 dpi ([#201](https://github.com/uw-cryo/asp_plot/issues/201)).
15
+ - The report no longer fails on a stereo directory whose `*-L.tif` or `*-D.tif` has been deleted ([#202](https://github.com/uw-cryo/asp_plot/issues/202)).
16
+
17
+ Repository size work, part one ([#201](https://github.com/uw-cryo/asp_plot/issues/201)). A `git clone` of this repository is 1.8 GB against a ~275 MB checkout, and the largest single cause is the committed example reports: PDFs do not delta-compress, so every regeneration is a wholly new permanent object, and seven report files had become 116 of them and 846 MB of history. This stops that growing. It does not shrink what is already there — that needs a history rewrite, which is the other half of #201.
18
+
19
+ ### Added
20
+ - **A cap on the resolution of figures embedded in PDF reports** (`asp_plot.report.FIGURE_MAX_DPI`, `asp_report --figure-max-dpi`, `0` disables). Figures are plotted at 220 dpi and then scaled down to fit the page, so the resolution that actually lands in the PDF is set by the placed size, not the save dpi: a 24-inch-wide multi-panel figure squeezed into 186 mm of a Letter page arrives at ~400 dpi. Measuring the Atlanta MVS report found 21.3 of its 21.4 MB was image data and 13.6 MB of it sat at 300–500 dpi — detail no screen shows and no printer reproduces, stored in full. `compile_report()` now downsamples each figure to a ceiling applied at its placed width, defaulting to 200 dpi. This takes the seven example reports from 79.9 MB to 59.0 MB, and it is a **change in output for every user**: reports are smaller and figures are capped at 200 dpi unless `--figure-max-dpi 0` is passed. 200 dpi rather than the ~150 dpi Ghostscript's `/ebook` preset targets, because that preset re-encodes plots as JPEG and rings around axis lines and tick labels; resolution-only downsampling loses nothing the page can show. PIL is already a dependency and fpdf2's `image()` takes a `PIL.Image`, so this adds no dependency and writes no temp files.
21
+ - **`docs/fetch_example_reports.sh`**, which downloads the example reports into `docs/_static/reports/` at docs-build time. It pins one `REPORTS_RELEASE` tag, so a docs build of an older commit fetches the reports of its era, and uses `curl -f` so a missing or renamed asset fails the build loudly instead of shipping a broken iframe.
22
+ - **`DEMBenchmark` scores every DEM on the points valid in all of them, and says which differences are real** ([#199](https://github.com/uw-cryo/asp_plot/issues/199)). Each DEM used to be scored on its own surviving points: its voids differ from the next DEM's, and the outlier cut runs per DEM, so a blend that fills voids was scored on harder ground (steep, tree-covered) than a single pair that skips it, and the ranking depends on that. Restricted to the shared points, the Atlanta 10-pair median mosaic moves from 8th to 3rd, and at UCSD the five-scene run referenced on the 8° scene moves from 4th to 1st while the pair that had been 1st drops to 4th. `run()` now also intersects the point ids every DEM kept (`bench.shared_ids`, `n_shared`) and rescores all of them there; those `*_shared_*` columns are what `summary_plot()` draws and sorts on, and the per-DEM `dh_*` columns stay as secondary numbers. Residuals along an ICESat-2 beam are correlated (ATL06-SR segments are 40 m long and posted every 20 m, and errors cluster with terrain and land cover), so the number of points overstates the evidence; a paired block bootstrap (`paired_block_bootstrap()`, `n_bootstrap=1000`, `--n-bootstrap`) resamples whole beam tracks (rgt, cycle, spot), or MOLA orbits, and evaluates every DEM on the same replicate. It gives 95 % intervals on each DEM's median and NMAD (`median_ci_*`, `nmad_ci_*`), the interval on its NMAD *difference* to the best DEM (`nmad_vs_best_ci_*`; includes zero means not separable) and the fraction of replicates on which it ranks first (`p_best`). Paired differences rather than overlapping intervals, because DEMs scored on the same points share most of their sampling variation: at Atlanta every top DEM's interval overlaps every other, yet the best DEM ranks first on 99 % of replicates. The figure shows the interval as an error bar on the ranked marker, prints it in brackets, and labels the best DEM `best` and every DEM not separable from it `≈ best`; the histogram uses the shared points; the CLI prints the headline columns and writes every column to the CSV. LOLA CSVs carry no track id, so on the Moon the bootstrap resamples points and warns that the intervals are optimistic. The IntersectionErr panel is dropped from the figure: across DEMs of different geometry the triangulation error ranks opposite to accuracy (the 5° pairs have the smallest, the five-ray runs the largest), so a panel of it beside the residuals misled; the `ie_*` columns stay in the table as a consistency check on a run.
23
+ - **The land-cover filter takes a list of groups** ([#199](https://github.com/uw-cryo/asp_plot/issues/199)). `Altimetry.filter_esa_worldcover(filter_out=...)` and `retain_only` accepted one ESA WorldCover group; they now also accept a list, and `DEMBenchmark(filter_out=["water", "trees"])` and `dem_benchmark --filter-out water --filter-out trees` pass one through (`--filter-out none` keeps every return). Dropping trees as well as water is the stable-terrain choice: years usually separate the imagery from the ICESat-2 passes and canopy height changes over that time, and at Atlanta a ranking on the tree points alone moved the best DEM from 1st to 7th. The filter runs on the altimetry sample before any DEM is scored, so every DEM is still compared on the same points. The default stays `"water"`, the report's setting, and a benchmark figure made with any other choice says so in its subtitle. `DEMBenchmark` raises on a name that is not a group (`"tree"` for `"trees"`) instead of scoring an unfiltered sample; the groups are `asp_plot.icesat2_source.WORLDCOVER_GROUPS`.
24
+
25
+ ### Changed
26
+ - **The report says what the intersection error is and is not** ([#199](https://github.com/uw-cryo/asp_plot/issues/199)). The caption of the DEM results page now defines it (the closest distance between the rays at the triangulated point; for a multi-view run, twice the mean distance from the point to its rays) and states that it measures how consistently the rays intersect, not how accurate the height is: it does not reflect the convergence angle, so it serves to find camera or correlation problems within a run and not to compare runs of different geometry. In the benchmark the 5° pairs had the smallest intersection error and the largest height error. The hillshade page's caption called the areas its zoomed subsets are picked from "low, medium, and high uncertainty"; it now says "intersection error", which is what they are picked by.
27
+ - **The example reports are no longer committed to git.** `reports/*.pdf` is gitignored and `git rm --cached`ed; the reports are published as assets on a dated [`reports-<date>`](https://github.com/uw-cryo/asp_plot/releases/tag/reports-2026-09-18) GitHub Release in this repository, together with the `*_figure_selections.yml` sidecars that generated them. A dated tag rather than the per-version releases, because `release.yml` creates a release per `pyproject.toml` bump and the reports do not change per version. `.readthedocs.yaml`'s `cp reports/*.pdf` step becomes `bash docs/fetch_example_reports.sh`; the reports page, the report links in the notebooks, and the local docs recipe in AGENTS.md all work as before, because the build still self-hosts the PDFs under `_static/reports/` — GitHub serves release assets as `content-disposition: attachment`, which an `<iframe>` cannot render. Nothing about generating a report locally changes. Publishing a regenerated set is documented in AGENTS.md; note that the release must be published *before* the commit that points at it.
28
+
29
+ ### Fixed
30
+ - **`release.yml` no longer picks a `reports-<date>` tag as the previous version.** Its previous-tag step was `git describe --tags --abbrev=0`, which returns the most recent tag reachable from `HEAD` — and the reports releases are tagged on `main`, so the next version release would have linked a changelog comparing `reports-2026-09-18...v3.4.0`. Now `--match 'v*'`. The tag-exists check greps for `v<version>` and was never affected.
31
+ - **`StereoFiles` no longer crashes on a stereo directory with no top-level `*-L.tif`** ([#202](https://github.com/uw-cryo/asp_plot/issues/202)). The constructor passed the result of `glob_file()` straight to `Raster()` to decide whether the run was mapprojected, so a missing left image raised `TypeError: expected str, bytes or os.PathLike object, not NoneType` instead of falling back to the missing-file placeholders the plots already draw. A missing left image now means not mapprojected. This happens in trimmed example directories (it blocked regenerating the Atlanta MVS report) and in multi-view runs, which keep their aligned images in `<prefix>-pairN/`; in that layout the lookup is now quiet, like its neighbours.
32
+ - **The match-point and disparity figures fall back to `*-lMask.tif` when `*-L.tif` and `*-D.tif` have been deleted** ([#202](https://github.com/uw-cryo/asp_plot/issues/202)). Both figures read the full-resolution file only for its size (or GSD, for mapprojected runs) to rescale onto the sub-sampled images, and showed the missing-files placeholder without it. ASP writes the left mask on the same grid and with the same georeferencing as the left image, and it is much smaller, so it tends to survive when disk space is reclaimed. The mask is also used to decide whether the run is mapprojected. The Atlanta MVS example directory had lost `L.tif` and `D.tif` in every pair, and its per-pair figures render again without re-running stereo.
33
+
34
+ ## [3.3.0] - 2026-09-18
35
+
36
+ The first piece of the scene-combination benchmark ([#169](https://github.com/uw-cryo/asp_plot/issues/169)): a way to score *many* DEMs against *one* altimetry sample. The report assesses a single DEM; the question of which scene combination, which processing flow (joint multi-view triangulation vs. pairwise stereo merged with `dem_mosaic`), or which parameter setting gives the best DEM needs every candidate scored against exactly the same points, side by side. The Atlanta MVS notebook had been doing that with an ad hoc loop; it is now a class, a figure, and a `dem_benchmark` command, and the notebook uses them to score six DEMs at once, where the single pairs explain the mosaic's result (the 5° pair lowers its accuracy) and the five-scene run has the lowest bias but not the lowest spread. No new dependencies; one new entry point, so the conda-forge feedstock recipe needs `dem_benchmark` added by hand this release.
37
+
38
+ ### Added
39
+ - **`DEMBenchmark`: score any number of DEMs against one ICESat-2 or LOLA/MOLA sample** (issue [#169](https://github.com/uw-cryo/asp_plot/issues/169)). `asp_plot/dem_benchmark.py` takes `{label: dem_fn}` plus the ATL06-SR parquet cache a report wrote (or a planetary CSV) and scores every DEM with the report's own recipe — replayed points, ESA WorldCover water filter, 3σ outlier cut — into a one-row-per-DEM table (`stats_df`, columns `STATS_COLUMNS`): coverage (`valid_pct`, `valid_area_km2`) inside a common area of interest (by default the intersection of all the DEM footprints, so runs with different crop windows compare fairly), the median and NMAD of the `*-IntersectionErr.tif` `point2dem` writes next to each `*-DEM.tif` (NaN for a mosaic, which has none), altimetry-minus-DEM n / median / NMAD / RMSE before and after a per-DEM `pc_align --compute-translation-only` with the translation it applied, and optionally each DEM's difference against one candidate named as the reference. `summary_plot()` draws the table as one row per DEM sorted best-first by post-alignment NMAD — coverage bars, IntersectionErr bars, and before→after dumbbells for the residual median and NMAD (a translation cannot change NMAD, so that panel separates bias, which alignment removes, from noise, which it cannot); `histogram_plot()` overlays the residual distributions. One `Altimetry` per DEM is kept in `bench.altimetry[label]`, so the usual per-DEM figures (`histogram_by_landcover()`, `mapview_plot_atl06sr_to_dem()`) can be drawn for any candidate. `pc_align` products and the translated DEM copies go under `<directory>/dem_benchmark/<label>/`, never into the candidates' own folders, and are reused on a re-run, which therefore makes no `pc_align` call and works offline; a missing `pc_align` binary degrades to pre-alignment scoring with a warning instead of failing. Large DEMs are read downsampled for the coverage statistics (capped at ~16 M samples per window).
40
+ - **A `dem_benchmark` command** wrapping it: positional DEMs as paths or `LABEL=PATH` (an ASP `run-DEM.tif` is labelled by its folder), `--parquet` for Earth or `--altimetry-csv` for the Moon/Mars, `--reference`, `--no-pc-align`, `--own-extent`, `--title`, and `--directory` / `--output-directory` / `--output-filename`; writes the summary figure, a `_histogram.png` twin, and the stats table as CSV, and prints the table. Documented in `docs/cli/dem_benchmark.md` and added to the CLI index (seven tools). The conda-forge recipe gains the entry point and its `--help` test command (the autotick bot does not sync those).
41
+ - **The Atlanta MVS notebook scores six DEMs on one ICESat-2 sample** with `DEMBenchmark` in place of its ad hoc two-DEM loop: the 3-scene MVS run, the 3-pair `dem_mosaic`, each of the three single pairs, and the 5-scene MVS run. The single pairs explain the mosaic: the 26.9° and 21.8° pairs score NMAD 0.64 and 0.72 m, the 5.1° pair 1.54 m, and `dem_mosaic` averages all three to 1.02 m — below either good pair alone — while the 3-scene MVS run's 0.71 m matches the best pairs and its bias is lower. The 5-scene run has the smallest bias (+0.12 m) but not the smallest spread (0.68 m), and its five-ray IntersectionErr median (0.19 m) reads higher than any pair's, so the notebook now explains why a narrow pair's small triangulation error is not a quality ranking. After `pc_align` every median lands at +0.16–0.27 m and no NMAD moves. The full matrix of scene combinations and flows is the benchmark notebook below.
42
+
43
+ - **The Atlanta scene-combination benchmark: `notebooks/WorldView/worldview_spacenet_benchmark.ipynb`** (issue [#169](https://github.com/uw-cryo/asp_plot/issues/169)). The systematic version of the MVS notebook's comparison: all ten pairs among the five same-pass scenes (5–32° convergence), five `dem_mosaic` blends of them (the three pairs sharing the reference, all ten with the default average, only the six above 15°, all ten by median, all ten weighted by convergence angle, and all ten weighted by propagated `VerticalStdDev` — the ASP manual's own *sfm_multiview* recipe, which needs every pair re-triangulated with `--propagate-errors`) and six multi-view runs (3, 3-wide, 4 and 5 scenes, plus the five scenes again with a different reference), twenty-one DEMs scored with `DEMBenchmark` on one ICESat-2 sample. The runs took about 20 h and ~150 GB on a laptop over two sessions. What it found: single-pair NMAD falls monotonically with convergence angle, 1.5–1.65 m at 5° to 0.60 m at 32°, and IntersectionErr does not reflect it; adding scenes to a joint triangulation changes NMAD little (0.70 → 0.70 → 0.65 → 0.66 m along the nested chain) but does remove bias; and pairwise + mosaic beats multi-view only when the weak pairs are *excluded* — every pair merged scores 0.99 m against MVS 5's 0.66 m, weighting by convergence or by propagated uncertainty only reaches 0.80–0.83 m, the median 0.67 m, and the six pairs above 15° 0.62 m. The mechanism, from ASP's source at the commit that built the DEMs (written up on the issue): a multi-view run is N−1 pairs of the *first image with each other image* — never scene-to-scene — followed by an unweighted least-squares ray intersection, so its quality is that of the reference's "star" of pairs. Referenced on the middle scene the star is 5.1°, 10.5°, 16.3° and 21.8°; referenced on the scene at the end of the pass it is 5.5°, 21.8°, 26.9° and 32.3°, and that run scores **0.58 m, the lowest NMAD of the twenty-one**. The notebook's docs card and toctree entry are added.
44
+ - **A second site for the benchmark, in the same notebook: SpaceNet UCSD WorldView-3, multi-date, over Mount Soledad** (issue [#169](https://github.com/uw-cryo/asp_plot/issues/169)). Chosen to differ from Atlanta in every respect its caveats list: the archive's 35 scenes are 35 separate collects, so any N-scene run is multi-date stereo, the regime the ASP manual discourages multi-view for; the 3 × 3 km crop has 244 m of relief, 30 % of its area steeper than 15°, 45 % built-up and 45 % tree cover. Five winter scenes (sun 30–40°, 1.1° to 24° off-nadir, pairs 8–34°), processed as three five-scene multi-view runs (referenced on the nadir, the 24° and the 8° scene), all ten pairs, and four `dem_mosaic` blends: seventeen DEMs at 1.2 m, 16 h on a laptop. The crop and scene selection are done in the notebook from metadata (Copernicus relief, WorldCover, ICESat-2 track density, pairwise convergence from the XMLs). What it found, with the caveat that a paired track bootstrap ([#199](https://github.com/uw-cryo/asp_plot/issues/199)) separates only the worst DEM from the other sixteen at this sample size: the point estimates do not reproduce the Atlanta convergence curve — the 8° pair (1.47 m) ties the best 25° pair (1.48 m) and the four pairs with the Nov 9 nadir scene score worst at every convergence, which suggests matching rather than geometry limits a pair in steep multi-date urban terrain; the narrowest reference star scores best rather than the widest (1.48 m nadir against 1.61 m for the 24° reference); and the joint triangulation ties the best pair while every blend of the ten pairs lands at 1.64–1.70 m. Exclusion by convergence angle, which worked at Atlanta, would drop the best-scoring pair here. The notebook ends with the two sites on the same axes and the practical rules revised to those consistent with both sites. Two `asp_plot` fixes came out of scoring it (below).
45
+
46
+ - **`asp_plot.stereo.read_match_file(fn)`**: read an ASP match file (binary or `--matches-as-txt`) into a DataFrame without a stereo directory. `StereoPlotter.get_match_point_df()` delegates to it; it exists because the benchmark notebook derives crop windows from `bundle_adjust` match files, and a `StereoPlotter` cannot be built once a run's `L.tif` has been cleaned up.
47
+
48
+ ### Changed
49
+ - `Alignment.apply_dem_translation()` accepts an `output_fn` for where to write the translated DEM (default unchanged: next to the source DEM), which is how the benchmark keeps candidate folders untouched.
50
+ - **The altimetry-minus-DEM outlier cut first drops gross outliers, then applies the 3σ cut as before** (`AltimetrySource._outlier_mask`, used by `atl06sr_to_dem_dh()`, `planetary_to_dem_dh()` and `filter_outliers()`, and so by every report and by `DEMBenchmark`). Points more than 30 normalized median absolute deviations from the median are removed before the mean/std statistics are computed. Motivation: over the UCSD site one whole ICESat-2 pass is a marine-layer cloud return 150–200 m above the ground — a fifth of the sample — and the mean/std cut, its std inflated to ~70 m by that cluster, removed nothing, leaving every DEM with an RMSE near 85 m and an NMAD a metre too high. A pure median/NMAD cut was tried first and rejected: 3 NMAD is far tighter than 3σ on the heavy-tailed residuals a DSM has against ICESat-2 in a city (it removed a fifth of the Atlanta sample and cut every NMAD there by a third, from 0.68 to 0.48 m for the five-scene run), and those tails are DEM error, not blunders. The gross gate touches only a few dozen of the ~7000 Atlanta points, so existing numbers move by at most 0.02 m, while the cloud pass at 65–75 NMAD is gone. `n_sigma` keeps its name and default. The benchmark notebook was re-executed with the gate (its Atlanta numbers moved by 0.01–0.02 m, no ranking changed); the earlier MVS notebook keeps its pre-gate numbers.
51
+
52
+ ### Fixed
53
+ - **Aligned-DEM residuals are now computed on exactly the points the unaligned DEM was scored on.** `pc_align`'s translation moves a DEM's holes and edges by a few metres, so a point that sampled NaN on the unaligned DEM — and therefore passed the outlier cut untested — could land on valid data in the translated copy and enter the post-alignment residuals unfiltered. Over UCSD that let six cloud returns sitting on a DEM hole into the aligned statistics as 150–210 m errors, lifting the post-alignment RMSE of some candidates from 2 m to 7–19 m while their NMAD barely moved. `atl06sr_to_dem_dh()` and `planetary_to_dem_dh()` now blank the aligned residual wherever the unaligned one is NaN, which also holds the report's pre/post-alignment plots to one sample.
54
+
8
55
  ## [3.2.0] - 2026-08-27
9
56
 
10
57
  A new view of what `bundle_adjust` did to the cameras — read from the run's own output folder, so no original camera files are needed — as a `bundle_adjust_cameras` command, a page of the `asp_report` PDF, and four notebook examples. The figure was reviewed on ten local runs (two to fourteen cameras, ASP 3.4 through 3.8); the row that earned its place is the ground effect from `triangulation_offsets.txt`, which is what tells a real camera error apart from a position/orientation trade in the solve. No new dependencies; one new entry point, so the conda-forge feedstock recipe needs the `bundle_adjust_cameras` entry point and test command added by hand this release.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: asp_plot
3
- Version: 3.2.0
3
+ Version: 3.4.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
@@ -386,7 +386,7 @@ class Alignment:
386
386
 
387
387
  return report
388
388
 
389
- def apply_dem_translation(self, output_prefix="pc_align/pc_align"):
389
+ def apply_dem_translation(self, output_prefix="pc_align/pc_align", output_fn=None):
390
390
  """
391
391
  Apply the pc_align translation to the DEM.
392
392
 
@@ -398,6 +398,13 @@ class Alignment:
398
398
  ----------
399
399
  output_prefix : str, optional
400
400
  Prefix for pc_align output files, default is "pc_align/pc_align"
401
+ output_fn : str, optional
402
+ Where to write the translated DEM. Default is None, which writes
403
+ ``<dem_fn without .tif>_pc_align_translated.tif`` next to the
404
+ source DEM (the report convention). Pass an explicit path to
405
+ keep the translated copy elsewhere, e.g. when many candidate
406
+ DEMs are aligned side by side and their folders should stay
407
+ untouched (:class:`asp_plot.dem_benchmark.DEMBenchmark`).
401
408
 
402
409
  Returns
403
410
  -------
@@ -479,7 +486,11 @@ class Alignment:
479
486
  t_srs.ImportFromWkt(src.ds.crs.to_wkt())
480
487
  proj_shift = self.get_proj_shift(src_c, src_shift, s_srs, t_srs, inv_trans=True)
481
488
 
482
- aligned_dem_fn = self.dem_fn.replace(".tif", "_pc_align_translated.tif")
489
+ if output_fn is None:
490
+ aligned_dem_fn = self.dem_fn.replace(".tif", "_pc_align_translated.tif")
491
+ else:
492
+ aligned_dem_fn = output_fn
493
+ os.makedirs(os.path.dirname(os.path.abspath(aligned_dem_fn)), exist_ok=True)
483
494
  print(f"\nWriting out: {aligned_dem_fn}\n")
484
495
 
485
496
  gdal_opt = ["COMPRESS=LZW", "TILED=YES", "PREDICTOR=3", "BIGTIFF=IF_SAFER"]
@@ -20,6 +20,11 @@ import numpy as np
20
20
  import rioxarray
21
21
  import xarray as xr
22
22
 
23
+ # A residual this many NMADs from the median is not a DEM error but a blunder in
24
+ # the altimetry (cloud returns, a bad fit); dropped before the n_sigma × std cut
25
+ # so that a large cluster of them cannot inflate the std and defeat it.
26
+ GROSS_OUTLIER_NMAD = 30
27
+
23
28
 
24
29
  class AltimetrySource:
25
30
  """Base for ICESat-2 and planetary altimetry sources.
@@ -66,13 +71,26 @@ class AltimetrySource:
66
71
  return rioxarray.open_rasterio(dem_fn, masked=True).squeeze()
67
72
 
68
73
  @staticmethod
69
- def _std_outlier_mask(dh, n_sigma):
70
- """Boolean mask keeping dh values within ``n_sigma`` × std of the mean.
74
+ def _outlier_mask(dh, n_sigma):
75
+ """Boolean mask keeping dh values within ``n_sigma`` × std of the mean,
76
+ after dropping gross outliers.
77
+
78
+ Two cuts. First, anything farther than ``GROSS_OUTLIER_NMAD`` (30)
79
+ normalized median absolute deviations from the median is dropped: no
80
+ DEM error puts a point 30 NMADs out, but a cloud return does — one
81
+ ICESat-2 pass of marine-layer cloud 150–200 m above the ground was a
82
+ fifth of the sample over a coastal site, and a plain mean/std cut,
83
+ its std inflated to ~70 m by that cluster, removed nothing and left
84
+ every DEM with an RMSE near 85 m. Then the usual ``n_sigma`` × std cut
85
+ about the mean of what survives, which is the cut every report has
86
+ always applied and, absent gross contamination, gives the same result
87
+ as before (the gross cut touches at most a few dozen points of the
88
+ ~7000 over Atlanta).
71
89
 
72
90
  Rows whose ``dh`` is NaN are kept (they carry no difference yet and
73
91
  must not be dropped). Returns ``None`` — signalling that no filtering
74
92
  should occur — when there are no finite values or the spread is
75
- degenerate (zero or non-finite std).
93
+ degenerate (zero or non-finite scale).
76
94
 
77
95
  Parameters
78
96
  ----------
@@ -84,11 +102,17 @@ class AltimetrySource:
84
102
  valid = dh.dropna().values
85
103
  if valid.size == 0:
86
104
  return None
87
- mean_val = np.mean(valid)
88
- std_val = np.std(valid)
105
+ median_val = np.median(valid)
106
+ nmad_val = 1.4826 * np.median(np.abs(valid - median_val))
107
+ if nmad_val == 0 or np.isnan(nmad_val):
108
+ return None
109
+ gross = (dh - median_val).abs() <= GROSS_OUTLIER_NMAD * nmad_val
110
+ core = dh[gross].dropna().values
111
+ mean_val = np.mean(core)
112
+ std_val = np.std(core)
89
113
  if std_val == 0 or np.isnan(std_val):
90
114
  return None
91
- mask = (dh - mean_val).abs() <= n_sigma * std_val
115
+ mask = gross & ((dh - mean_val).abs() <= n_sigma * std_val)
92
116
  return mask | dh.isna()
93
117
 
94
118
  def _write_csv_to_directory(self, df, filename):
@@ -1,5 +1,6 @@
1
1
  import click
2
2
 
3
+ from asp_plot.report import FIGURE_MAX_DPI
3
4
  from asp_plot.report_pipeline import ReportConfig, run_report
4
5
 
5
6
 
@@ -128,6 +129,14 @@ def _reconstruct_command():
128
129
  default=None,
129
130
  help="Title for the report. Default: Directory name of ASP processing.",
130
131
  )
132
+ @click.option(
133
+ "--figure-max-dpi",
134
+ prompt=False,
135
+ default=FIGURE_MAX_DPI,
136
+ show_default=True,
137
+ type=int,
138
+ help="Resolution ceiling for figures embedded in the PDF, applied at the size each figure is placed on the page. Figures are plotted at 220 dpi and then scaled down to the page, so wide multi-panel figures otherwise land at 400+ effective dpi and inflate the file for detail no screen or printer shows. Pass 0 to embed the full-resolution figures.",
139
+ )
131
140
  def main(
132
141
  directory,
133
142
  bundle_adjust_prefix,
@@ -146,6 +155,7 @@ def main(
146
155
  reuse_selections,
147
156
  report_filename,
148
157
  report_title,
158
+ figure_max_dpi,
149
159
  ):
150
160
  """
151
161
  Generate a comprehensive report of ASP processing results.
@@ -172,6 +182,7 @@ def main(
172
182
  reuse_selections=reuse_selections,
173
183
  report_filename=report_filename,
174
184
  report_title=report_title,
185
+ figure_max_dpi=figure_max_dpi,
175
186
  report_command=_reconstruct_command(),
176
187
  )
177
188
  run_report(config)