asp-plot 3.0.0__tar.gz → 3.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {asp_plot-3.0.0 → asp_plot-3.1.0}/AGENTS.md +1 -1
- {asp_plot-3.0.0 → asp_plot-3.1.0}/ARCHITECTURE.md +3 -2
- {asp_plot-3.0.0 → asp_plot-3.1.0}/CHANGELOG.md +9 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/PKG-INFO +1 -1
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/alignment.py +53 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/altimetry.py +8 -8
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/report.py +125 -33
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/report_captions.py +37 -20
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/report_pipeline.py +37 -2
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/stereo.py +114 -22
- {asp_plot-3.0.0 → asp_plot-3.1.0}/pyproject.toml +1 -1
- {asp_plot-3.0.0 → asp_plot-3.1.0}/.flake8 +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/.github/ISSUE_TEMPLATE/problem-report.yml +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/.github/workflows/claude-code-review.yml +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/.github/workflows/claude.yml +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/.github/workflows/release.yml +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/.github/workflows/run-tests.yml +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/.gitignore +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/.pre-commit-config.yaml +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/.readthedocs.yaml +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/CLAUDE.md +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/LICENSE +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/README.md +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/__init__.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/altimetry_plots.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/altimetry_source.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/asp_log.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/bodies.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/bundle_adjust.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/cli/__init__.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/cli/asp_report.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/cli/csm_camera_plot.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/cli/gallery.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/cli/request_planetary_altimetry.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/cli/stereo_geom.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/csm_analysis.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/csm_camera.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/csm_io.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/gallery.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/icesat2_source.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/mapproject.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/planetary_source.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/processing_parameters.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/scenes.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/selections.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/sensors/__init__.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/sensors/aster.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/sensors/base.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/sensors/dimap.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/sensors/dimap_v1.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/sensors/rpc.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/sensors/worldview.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/stereo_geometry.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/stereopair_metadata_parser.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/asp_plot/utils.py +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/conda-forge-recipe/meta.yaml +0 -0
- {asp_plot-3.0.0 → asp_plot-3.1.0}/environment.yml +0 -0
|
@@ -76,7 +76,7 @@ The rest is automated: `release.yml` detects the version bump, creates a GitHub
|
|
|
76
76
|
ASP output files follow specific naming patterns (find them with the `glob_file()` utility):
|
|
77
77
|
- DEMs: `*-DEM.tif` or `*_dem.tif`
|
|
78
78
|
- Disparity: `*-F.tif`
|
|
79
|
-
- Match files: `*.match`
|
|
79
|
+
- Match files: `*.match` (binary) or `*__*.txt` (plain text, from ASP >= 3.7.0 `--matches-as-txt`; binary preferred when both exist)
|
|
80
80
|
- Bundle adjust residuals: `*-initial_residuals_pointmap.csv`, `*-final_residuals_pointmap.csv`
|
|
81
81
|
- Log files: `*log-bundle_adjust*.txt`, `*log-stereo*.txt`, `*log-point2dem*.txt`
|
|
82
82
|
|
|
@@ -46,7 +46,7 @@ The package is organized by functionality, with each module focused on a specifi
|
|
|
46
46
|
|
|
47
47
|
**`report.py`** - PDF report generation using fpdf2
|
|
48
48
|
- `ReportSection`: Dataclass representing a report figure (title, image path, caption)
|
|
49
|
-
- `AlignmentReportPage`: Dataclass for the pc_align +
|
|
49
|
+
- `AlignmentReportPage`: Dataclass for the pc_align + altimetry alignment workflow (title, parameters dict, flat stats dict from `pc_align_report()`, description paragraph, status message, optional figure + caption). Rendered alongside `ReportSection` by `compile_report()`. Body text blocks are left-aligned (not justified) to avoid word-spacing gaps on long lines. `_add_alignment_stats_tables()` splits the flat stats dict (`_split_alignment_stats()`) into two side-by-side tables: "Error Statistics (m)" with one row per `<stat>_beg`/`<stat>_end` pair and Before / After / Change columns (row labels via `_ALIGNMENT_STAT_LABELS`, Change from `_fmt_pct_change()`), and "Translation (m)" with one row per remaining key (`north_shift` → `North`, … `translation_magnitude` → `Magnitude |T|` via `_ALIGNMENT_TRANSLATION_LABELS`). Either table takes the full width when the other is empty.
|
|
50
50
|
- `ReportMetadata`: Dataclass for DEM metadata displayed on the title page (dimensions, GSD, CRS, nodata %, elevation range, DEM filename, reference DEM, acquisition dates). The "Acquisition Date(s)" row is added to the summary table only when `acquisition_dates` is non-empty.
|
|
51
51
|
- `ASPReportPDF`: FPDF subclass with custom header/footer and page numbers
|
|
52
52
|
- `compile_report()`: Assembles title page, Processing Parameters (page 2), figure sections with captions, and any trailing alignment pages into a PDF. Accepts optional `report_command` string to record the CLI invocation. Figures are automatically scaled to fit page dimensions, preventing overflow/cutoff. `sections` is a mixed list of `ReportSection | AlignmentReportPage`; dispatch is by `isinstance` check.
|
|
@@ -109,6 +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
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
|
|
113
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()`
|
|
114
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.
|
|
@@ -180,7 +181,7 @@ The package is organized by functionality, with each module focused on a specifi
|
|
|
180
181
|
- `pc_align_dem_to_atl06sr()`: ICESat-2 path, csv-format `1:lon 2:lat 3:height_above_datum`
|
|
181
182
|
- `pc_align_dem_to_planetary_csv(planetary_csv, body, ...)`: MOLA/LOLA path. Uses csv-format `1:lon 2:lat 3:radius_m` and `--datum D_MARS`/`D_MOON` (per ASAP-Stereo's CTX cookbook). Default `max_displacement=500` m
|
|
182
183
|
- Both public methods keep their signatures/validation/errors and delegate to a shared `_run_pc_align(csv, csv_format, max_displacement, datum=...)` (#127); generated argv is byte-identical to before, with `--datum` emitted only on the planetary path
|
|
183
|
-
- `pc_align_report()`: Extracts begin/end percentiles
|
|
184
|
+
- `pc_align_report()`: Extracts begin/end percentiles, the ASP >= 3.7.0 Mean/StdDev/RMSE/Median/NMAD summary (keys absent for older logs), and the N-E-D translation from the pc_align log; the report page shows Median/NMAD/RMSE + translation when the log has them, else the percentiles (`report_pipeline._stats_row_from_result()`)
|
|
184
185
|
- `apply_dem_translation()`: Applies pc_align's Cartesian translation to the DEM (geotransform shift + scalar add to pixel values, no resampling). Picks the right body-centered geocentric source CRS via the module-level `_GEOCENTRIC_PROJ` dict — Earth uses EPSG:4978, Mars/Moon use PROJ strings (`+proj=geocent +R=...`) because PROJ refuses to convert across celestial bodies
|
|
185
186
|
- Used by `Altimetry` class for DEM-to-altimetry alignment on Earth, Mars, and Moon
|
|
186
187
|
|
|
@@ -5,6 +5,15 @@ 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.1.0] - 2026-08-27
|
|
9
|
+
|
|
10
|
+
A compatibility release for two things ASP 3.7.0 changed in the files the report reads. `pc_align` now writes Mean/StdDev/RMSE/Median/NMAD error statistics to its log, and the alignment page shows Median, NMAD and RMSE before and after alignment in place of the 16/50/84 percentiles ([#146](https://github.com/uw-cryo/asp_plot/issues/146)); older logs keep the percentiles, with no version sniffing. And a `parallel_stereo`/`bundle_adjust` run made with `--matches-as-txt` writes plain-text match files instead of binary `.match`, which previously left the match-point page with a "missing match file" placeholder — both formats are now discovered and parsed into the same DataFrame ([#147](https://github.com/uw-cryo/asp_plot/issues/147)). No new dependencies and no entry-point changes.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`pc_align_report()` parses the error statistics ASP 3.7.0 added to the `pc_align` log** (issue [#146](https://github.com/uw-cryo/asp_plot/issues/146)): the `Input stats (meters):` / `Output stats (meters):` lines become `mean_beg/end`, `stddev_beg/end`, `rmse_beg/end`, `median_beg/end`, `nmad_beg/end` alongside the existing percentiles and translation, and flow into `Altimetry.alignment_report_df`. The alignment report page (ICESat-2 and LOLA/MOLA) now shows `Median`, `NMAD` and `RMSE` before/after alignment in place of the 16/50/84 percentiles, with the column description updated; `mean`/`stddev` stay in the dataframe only. Logs from ASP < 3.7.0 parse exactly as before, without the new keys, and the page keeps showing the percentiles for them — no version sniffing, the absence of the new stats is the signal. The seven committed example reports in `reports/` are regenerated with the new page.
|
|
14
|
+
- **The `pc_align` log parser is now regression-tested** against real logs from both generations — the existing 2024-11 fixtures and a new ASP 3.8.0-alpha LOLA log (`tests/test_data/pc_align/pc_align_lola-log-pc_align.txt`) — which also confirmed the percentile and translation lines we key off are unchanged in 3.8.0.
|
|
15
|
+
- **Plain-text ASP match files are discovered and parsed** (issue [#147](https://github.com/uw-cryo/asp_plot/issues/147)). ASP 3.7.0 added a text match-file format (`<prefix>-<A>__<B>.txt`, one `x1 y1 unc1 x2 y2 unc2` line per match), and `parallel_stereo`/`bundle_adjust --matches-as-txt` write it *instead of* `.match` — so a stereo directory produced with that switch previously got the "missing match file" placeholder on the match-point page. `StereoFiles` now also looks for `*__*.txt` (anchored on the `__` image-name separator and required to open with a six-field match row, so logs and alignment matrices are never mistaken for it — even with a run prefix like `my__run`), `StereoPlotter.get_match_point_df()` reads either format into the same `x1/y1/x2/y2` DataFrame, and the `.vwip` interest-point pairing works off the text stem too. The format is detected from the file's bytes (the binary header has NUL bytes; text never does), not the extension, so a renamed file still parses; when both forms coexist the binary file is preferred, as before; text files are read directly, never through the `.csv` cache a binary conversion leaves behind. A text twin of the raw-image fixture, converted with `ipmatch --binary-to-txt` (ASP 3.8.0-alpha), is committed so the two readers are checked against each other. `.vwip` files remain binary-only — stereo never writes text ones.
|
|
16
|
+
|
|
8
17
|
## [3.0.0] - 2026-08-24
|
|
9
18
|
|
|
10
19
|
A breaking standardization of the command-line interfaces ([#60](https://github.com/uw-cryo/asp_plot/issues/60)), done deliberately as a clean break — no aliases, no deprecation period — while the user base is small. Every multi-word option across the five CLIs (`asp_report`, `stereo_geom`, `csm_camera_plot`, `gallery`, `request_planetary_altimetry`) moves from underscores to the hyphenated style ASP itself uses (`--stereo_directory` → `--stereo-directory`), booleans become single switches for the non-default behavior (`--add_basemap False` → `--no-basemap`), and `--bundle_adjust_directory` becomes `--bundle-adjust-prefix`, matching both the name and the semantics of ASP's own option. The Python API is unchanged.
|
|
@@ -17,6 +17,43 @@ logging.basicConfig(level=logging.WARNING)
|
|
|
17
17
|
logger = logging.getLogger(__name__)
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
# Labels ASP >= 3.7.0 prints on the "Input stats (meters):" / "Output stats
|
|
21
|
+
# (meters):" pc_align log lines, mapped to the keys pc_align_report() uses.
|
|
22
|
+
_PC_ALIGN_STATS_FIELDS = {
|
|
23
|
+
"Mean": "mean",
|
|
24
|
+
"StdDev": "stddev",
|
|
25
|
+
"RMSE": "rmse",
|
|
26
|
+
"Median": "median",
|
|
27
|
+
"NMAD": "nmad",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_pc_align_stats_line(line, suffix):
|
|
32
|
+
"""Parse one ``Input/Output stats (meters):`` pc_align log line.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
line : str
|
|
37
|
+
e.g. ``"... Input stats (meters): Mean: 71.4632, StdDev: 134.758,
|
|
38
|
+
RMSE: 152.535, Median: 5.71217, NMAD: 3.20963"``
|
|
39
|
+
suffix : str
|
|
40
|
+
``"beg"`` for the Input line, ``"end"`` for the Output line.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
dict
|
|
45
|
+
``{"mean_<suffix>": float, "stddev_<suffix>": float, ...}`` for every
|
|
46
|
+
label found on the line; labels that are missing are left out rather
|
|
47
|
+
than raising, so a future change to the line degrades gracefully.
|
|
48
|
+
"""
|
|
49
|
+
parsed = {}
|
|
50
|
+
for label, key in _PC_ALIGN_STATS_FIELDS.items():
|
|
51
|
+
match = re.search(rf"{label}: (-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)", line)
|
|
52
|
+
if match:
|
|
53
|
+
parsed[f"{key}_{suffix}"] = float(match.group(1))
|
|
54
|
+
return parsed
|
|
55
|
+
|
|
56
|
+
|
|
20
57
|
class Alignment:
|
|
21
58
|
"""
|
|
22
59
|
Perform DEM alignment using point cloud alignment techniques.
|
|
@@ -274,11 +311,22 @@ class Alignment:
|
|
|
274
311
|
dict
|
|
275
312
|
Dictionary containing alignment metrics:
|
|
276
313
|
- p16_beg, p50_beg, p84_beg: Error percentiles before alignment
|
|
314
|
+
- mean_beg, stddev_beg, rmse_beg, median_beg, nmad_beg: Error
|
|
315
|
+
statistics before alignment (ASP >= 3.7.0 only; absent from
|
|
316
|
+
the dict when the log predates them)
|
|
277
317
|
- p16_end, p50_end, p84_end: Error percentiles after alignment
|
|
318
|
+
- mean_end, stddev_end, rmse_end, median_end, nmad_end: Error
|
|
319
|
+
statistics after alignment (ASP >= 3.7.0 only)
|
|
278
320
|
- north_shift, east_shift, down_shift: Translation vector components
|
|
279
321
|
in North-East-Down (NED) coordinate frame, in meters
|
|
280
322
|
- translation_magnitude: Magnitude of translation vector
|
|
281
323
|
|
|
324
|
+
All errors are the absolute point-to-point (or point-to-plane)
|
|
325
|
+
distances pc_align reports, in meters. ``median_*`` duplicates
|
|
326
|
+
``p50_*``; ``nmad_*`` is the robust spread and ``rmse_*`` the
|
|
327
|
+
outlier-sensitive one, so a large RMSE alongside a small NMAD flags
|
|
328
|
+
a tail of gross errors rather than a broad misfit.
|
|
329
|
+
|
|
282
330
|
Notes
|
|
283
331
|
-----
|
|
284
332
|
This method expects the log file to contain specific keyword patterns
|
|
@@ -317,6 +365,11 @@ class Alignment:
|
|
|
317
365
|
"p84_end": float(values[2]),
|
|
318
366
|
}
|
|
319
367
|
report = report | percentile_dict
|
|
368
|
+
# ASP >= 3.7.0 adds a one-line summary after each percentile line
|
|
369
|
+
if "Input stats (meters):" in line:
|
|
370
|
+
report = report | _parse_pc_align_stats_line(line, "beg")
|
|
371
|
+
if "Output stats (meters):" in line:
|
|
372
|
+
report = report | _parse_pc_align_stats_line(line, "end")
|
|
320
373
|
if "Translation vector (North-East-Down, meters):" in line:
|
|
321
374
|
ned_shift = np.genfromtxt(
|
|
322
375
|
[line.split("Vector3")[1][1:-2]], delimiter=","
|
|
@@ -59,10 +59,10 @@ class AlignmentResult:
|
|
|
59
59
|
One of:
|
|
60
60
|
- ``"insufficient_points"``: not enough ATL06-SR points for
|
|
61
61
|
pc_align to run (the aligned DEM is removed if one was written).
|
|
62
|
-
- ``"no_improvement"``: pc_align ran but p50 did not improve
|
|
62
|
+
- ``"no_improvement"``: pc_align ran but the median (p50) did not improve
|
|
63
63
|
toward 0 by more than the ``improvement_threshold_pct``; the
|
|
64
64
|
aligned DEM has been removed.
|
|
65
|
-
- ``"success"``: p50 improved by more than the threshold; the
|
|
65
|
+
- ``"success"``: the median (p50) improved by more than the threshold; the
|
|
66
66
|
aligned DEM is retained and ``Altimetry.aligned_dem_fn`` points
|
|
67
67
|
to it.
|
|
68
68
|
alignment_report_df : pandas.DataFrame
|
|
@@ -729,11 +729,11 @@ class Altimetry:
|
|
|
729
729
|
reason = (
|
|
730
730
|
f"Translation magnitude is below {min_translation_threshold*100:.0f}% "
|
|
731
731
|
"of the DEM GSD, so no aligned DEM was written despite a "
|
|
732
|
-
f"{improvement_repr}
|
|
732
|
+
f"{improvement_repr} median reduction."
|
|
733
733
|
)
|
|
734
734
|
else:
|
|
735
735
|
reason = (
|
|
736
|
-
f"
|
|
736
|
+
f"median {p50_beg:.2f} m -> {p50_end:.2f} m, "
|
|
737
737
|
f"{improvement_repr} <= {improvement_threshold_pct:.1f}% "
|
|
738
738
|
"threshold. Aligned DEM removed."
|
|
739
739
|
)
|
|
@@ -795,7 +795,7 @@ class Altimetry:
|
|
|
795
795
|
``--max-displacement`` for pc_align, in meters. Default 500
|
|
796
796
|
(ASAP-Stereo's CTX cookbook recommendation).
|
|
797
797
|
improvement_threshold_pct : float, optional
|
|
798
|
-
Minimum p50 reduction (%) required to keep the aligned DEM.
|
|
798
|
+
Minimum median (p50) reduction (%) required to keep the aligned DEM.
|
|
799
799
|
min_translation_threshold : float, optional
|
|
800
800
|
Minimum translation magnitude as a fraction of the DEM GSD.
|
|
801
801
|
minimum_points : int, optional
|
|
@@ -897,11 +897,11 @@ class Altimetry:
|
|
|
897
897
|
reason = (
|
|
898
898
|
f"Translation magnitude is below {min_translation_threshold*100:.0f}% "
|
|
899
899
|
f"of the DEM GSD ({gsd:.2f} m), so no aligned DEM was "
|
|
900
|
-
f"written despite a {improvement_repr}
|
|
900
|
+
f"written despite a {improvement_repr} median reduction."
|
|
901
901
|
)
|
|
902
902
|
else:
|
|
903
903
|
reason = (
|
|
904
|
-
f"
|
|
904
|
+
f"median {p50_beg:.2f} m -> {p50_end:.2f} m, "
|
|
905
905
|
f"{improvement_repr} <= {improvement_threshold_pct:.1f}% "
|
|
906
906
|
"threshold."
|
|
907
907
|
)
|
|
@@ -1034,7 +1034,7 @@ class Altimetry:
|
|
|
1034
1034
|
aligned_dem_fn=self.aligned_dem_fn,
|
|
1035
1035
|
improvement_pct=improvement_pct,
|
|
1036
1036
|
message=(
|
|
1037
|
-
f"
|
|
1037
|
+
f"Median improved from {p50_beg:.2f} m -> {p50_end:.2f} m "
|
|
1038
1038
|
f"({improvement_pct:.1f}% reduction). Aligned DEM written to "
|
|
1039
1039
|
f"{self.aligned_dem_fn}."
|
|
1040
1040
|
),
|
|
@@ -53,9 +53,10 @@ class AlignmentReportPage:
|
|
|
53
53
|
to skip.
|
|
54
54
|
stats_row : dict
|
|
55
55
|
Single-row alignment statistics (e.g. p16_beg/p50_beg/... from
|
|
56
|
-
``pc_align_report``). Rendered as
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
``pc_align_report``). Rendered as two side-by-side tables: one
|
|
57
|
+
row per error statistic (Before / After / Change) and one row per
|
|
58
|
+
translation component. Values are formatted to two significant
|
|
59
|
+
figures. Use an empty dict to skip.
|
|
59
60
|
description : str
|
|
60
61
|
Long-form explanation of pc_align and the meaning of each column in
|
|
61
62
|
the parameters and stats tables. Rendered between the stats table
|
|
@@ -325,7 +326,7 @@ def _render_alignment_report_page(pdf, page):
|
|
|
325
326
|
pdf.ln(3)
|
|
326
327
|
|
|
327
328
|
if page.stats_row:
|
|
328
|
-
|
|
329
|
+
_add_alignment_stats_tables(pdf, page.stats_row)
|
|
329
330
|
pdf.ln(3)
|
|
330
331
|
|
|
331
332
|
if page.description:
|
|
@@ -391,21 +392,70 @@ def _add_alignment_parameters_table(pdf, parameters):
|
|
|
391
392
|
pdf.cell(col_w, 6, str(val), border=1, new_x="LMARGIN", new_y="NEXT")
|
|
392
393
|
|
|
393
394
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
"
|
|
398
|
-
"
|
|
395
|
+
# Row labels for the error-statistics table, keyed by the pc_align_report()
|
|
396
|
+
# stat name (the part of the key before _beg/_end).
|
|
397
|
+
_ALIGNMENT_STAT_LABELS = {
|
|
398
|
+
"median": "Median",
|
|
399
|
+
"nmad": "NMAD",
|
|
400
|
+
"rmse": "RMSE",
|
|
401
|
+
"mean": "Mean",
|
|
402
|
+
"stddev": "StdDev",
|
|
403
|
+
"p16": "p16",
|
|
404
|
+
"p50": "p50",
|
|
405
|
+
"p84": "p84",
|
|
399
406
|
}
|
|
400
407
|
|
|
408
|
+
# Row labels for the translation table.
|
|
409
|
+
_ALIGNMENT_TRANSLATION_LABELS = {
|
|
410
|
+
"north_shift": "North",
|
|
411
|
+
"east_shift": "East",
|
|
412
|
+
"down_shift": "Down",
|
|
413
|
+
"translation_magnitude": "Magnitude |T|",
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _split_alignment_stats(stats_row):
|
|
418
|
+
"""Split a flat pc_align_report() row into the two alignment tables.
|
|
419
|
+
|
|
420
|
+
Returns ``(stats, translation)``: ``stats`` is a list of
|
|
421
|
+
``(label, before, after)`` for every ``<stat>_beg`` key that has a
|
|
422
|
+
matching ``<stat>_end``; ``translation`` is a list of ``(label, value)``
|
|
423
|
+
for everything else, in row order.
|
|
424
|
+
"""
|
|
425
|
+
stats, translation, consumed = [], [], set()
|
|
426
|
+
for key, val in stats_row.items():
|
|
427
|
+
if key.endswith("_beg") and f"{key[:-4]}_end" in stats_row:
|
|
428
|
+
stat = key[:-4]
|
|
429
|
+
stats.append(
|
|
430
|
+
(_ALIGNMENT_STAT_LABELS.get(stat, stat), val, stats_row[f"{stat}_end"])
|
|
431
|
+
)
|
|
432
|
+
consumed.update({key, f"{stat}_end"})
|
|
433
|
+
for key, val in stats_row.items():
|
|
434
|
+
if key not in consumed:
|
|
435
|
+
translation.append((_ALIGNMENT_TRANSLATION_LABELS.get(key, key), val))
|
|
436
|
+
return stats, translation
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _fmt_pct_change(before, after):
|
|
440
|
+
"""Percent change from before to after (negative = improvement)."""
|
|
441
|
+
try:
|
|
442
|
+
b, a = float(before), float(after)
|
|
443
|
+
except (TypeError, ValueError):
|
|
444
|
+
return "n/a"
|
|
445
|
+
if not (math.isfinite(b) and math.isfinite(a)) or b == 0:
|
|
446
|
+
return "n/a"
|
|
447
|
+
return f"{(a - b) / b * 100:+.1f}%"
|
|
448
|
+
|
|
401
449
|
|
|
402
|
-
def
|
|
403
|
-
"""Render
|
|
450
|
+
def _add_alignment_stats_tables(pdf, stats_row):
|
|
451
|
+
"""Render the alignment statistics as two side-by-side tables.
|
|
404
452
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
453
|
+
Left, "Error Statistics (m)": one row per statistic with Before /
|
|
454
|
+
After / Change columns. Right, "Translation (m)": one row per
|
|
455
|
+
component of the applied translation. Both come from the flat
|
|
456
|
+
``pc_align_report()`` row via ``_split_alignment_stats``; the number
|
|
457
|
+
of statistic rows follows what the log provided (Median/NMAD/RMSE for
|
|
458
|
+
ASP >= 3.7.0, p16/p50/p84 before that).
|
|
409
459
|
|
|
410
460
|
Parameters
|
|
411
461
|
----------
|
|
@@ -413,28 +463,70 @@ def _add_alignment_stats_row_table(pdf, stats_row):
|
|
|
413
463
|
stats_row : dict
|
|
414
464
|
Ordered dict-like of ``{column_name: value}``.
|
|
415
465
|
"""
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
pdf.ln(1)
|
|
419
|
-
|
|
420
|
-
keys = list(stats_row.keys())
|
|
421
|
-
if not keys:
|
|
466
|
+
stats, translation = _split_alignment_stats(stats_row)
|
|
467
|
+
if not stats and not translation:
|
|
422
468
|
return
|
|
423
469
|
|
|
424
470
|
usable_w = pdf.w - pdf.l_margin - pdf.r_margin
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
pdf.
|
|
471
|
+
gap = 8
|
|
472
|
+
if stats and translation:
|
|
473
|
+
left_w = (usable_w - gap) * 0.6
|
|
474
|
+
right_w = usable_w - gap - left_w
|
|
475
|
+
else: # a lone table takes the full width
|
|
476
|
+
left_w = right_w = usable_w
|
|
477
|
+
row_h = 6
|
|
478
|
+
x_left, y_top = pdf.l_margin, pdf.get_y()
|
|
479
|
+
x_right = pdf.l_margin + left_w + gap if stats else pdf.l_margin
|
|
480
|
+
|
|
481
|
+
def header(x, y, widths, labels):
|
|
482
|
+
pdf.set_xy(x, y)
|
|
483
|
+
pdf.set_font("Helvetica", "B", 9)
|
|
484
|
+
pdf.set_fill_color(220, 220, 220)
|
|
485
|
+
for w, label in zip(widths, labels):
|
|
486
|
+
pdf.cell(w, row_h, label, border=1, fill=True, align="C")
|
|
487
|
+
|
|
488
|
+
def body_row(x, y, widths, values):
|
|
489
|
+
pdf.set_xy(x, y)
|
|
490
|
+
pdf.set_font("Helvetica", "", 9)
|
|
491
|
+
for i, (w, val) in enumerate(zip(widths, values)):
|
|
492
|
+
pdf.cell(w, row_h, val, border=1, align="L" if i == 0 else "C")
|
|
433
493
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
pdf.
|
|
437
|
-
|
|
494
|
+
bottoms = []
|
|
495
|
+
if stats:
|
|
496
|
+
pdf.set_xy(x_left, y_top)
|
|
497
|
+
pdf.set_font("Helvetica", "B", 10)
|
|
498
|
+
pdf.cell(left_w, 6, "Error Statistics (m)", new_x="LMARGIN", new_y="NEXT")
|
|
499
|
+
widths = [left_w * 0.31, left_w * 0.23, left_w * 0.23, left_w * 0.23]
|
|
500
|
+
y = y_top + 6
|
|
501
|
+
header(x_left, y, widths, ["Statistic", "Before", "After", "Change"])
|
|
502
|
+
for label, before, after in stats:
|
|
503
|
+
y += row_h
|
|
504
|
+
body_row(
|
|
505
|
+
x_left,
|
|
506
|
+
y,
|
|
507
|
+
widths,
|
|
508
|
+
[
|
|
509
|
+
label,
|
|
510
|
+
_fmt_sig(before),
|
|
511
|
+
_fmt_sig(after),
|
|
512
|
+
_fmt_pct_change(before, after),
|
|
513
|
+
],
|
|
514
|
+
)
|
|
515
|
+
bottoms.append(y + row_h)
|
|
516
|
+
|
|
517
|
+
if translation:
|
|
518
|
+
pdf.set_xy(x_right, y_top)
|
|
519
|
+
pdf.set_font("Helvetica", "B", 10)
|
|
520
|
+
pdf.cell(right_w, 6, "Translation (m)", new_x="LMARGIN", new_y="NEXT")
|
|
521
|
+
widths = [right_w * 0.6, right_w * 0.4]
|
|
522
|
+
y = y_top + 6
|
|
523
|
+
header(x_right, y, widths, ["Component", "Value"])
|
|
524
|
+
for label, val in translation:
|
|
525
|
+
y += row_h
|
|
526
|
+
body_row(x_right, y, widths, [label, _fmt_sig(val)])
|
|
527
|
+
bottoms.append(y + row_h)
|
|
528
|
+
|
|
529
|
+
pdf.set_xy(pdf.l_margin, max(bottoms))
|
|
438
530
|
|
|
439
531
|
|
|
440
532
|
def _render_command_block(pdf, label, cmd):
|
|
@@ -103,17 +103,25 @@ EARTH_ALIGNMENT_DESCRIPTION = (
|
|
|
103
103
|
"magnitude (as a fraction of the DEM GSD) required to "
|
|
104
104
|
"write out an aligned DEM.\n"
|
|
105
105
|
" - improvement_threshold_pct: minimum percentage "
|
|
106
|
-
"reduction in
|
|
106
|
+
"reduction in the median residual required to keep the aligned DEM on "
|
|
107
107
|
"disk; below this, the aligned DEM is removed.\n\n"
|
|
108
|
-
"
|
|
109
|
-
"
|
|
110
|
-
"
|
|
111
|
-
"
|
|
112
|
-
" -
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
"
|
|
116
|
-
"
|
|
108
|
+
"Error Statistics (above, in meters): each row is one "
|
|
109
|
+
"statistic of the DEM-vs-ICESat absolute height residuals, "
|
|
110
|
+
"Before and After alignment; Change is the percent change "
|
|
111
|
+
"from Before to After, so negative is an improvement.\n"
|
|
112
|
+
" - Median: median residual.\n"
|
|
113
|
+
" - NMAD: normalized median absolute deviation; a robust "
|
|
114
|
+
"spread, insensitive to outliers.\n"
|
|
115
|
+
" - RMSE: root-mean-square residual; outlier-sensitive, so "
|
|
116
|
+
"a large RMSE next to a small NMAD flags a tail of gross "
|
|
117
|
+
"errors rather than a broad misfit.\n"
|
|
118
|
+
" - p16 / p50 / p84: shown instead of the above when the "
|
|
119
|
+
"pc_align log predates ASP 3.7.0 (which added the Median / "
|
|
120
|
+
"NMAD / RMSE summary): 16th / 50th / 84th percentile of the "
|
|
121
|
+
"residuals; p50 is the median.\n\n"
|
|
122
|
+
"Translation (above, in meters): the applied translation "
|
|
123
|
+
"vector as North / East / Down components and its "
|
|
124
|
+
"magnitude |T|. Positive Down means the DEM was too high."
|
|
117
125
|
)
|
|
118
126
|
|
|
119
127
|
|
|
@@ -167,15 +175,24 @@ def planetary_alignment_description(instrument):
|
|
|
167
175
|
f"magnitude (as a fraction of the DEM GSD) required "
|
|
168
176
|
f"to write out an aligned DEM.\n"
|
|
169
177
|
f" - improvement_threshold_pct: minimum percentage "
|
|
170
|
-
f"reduction in
|
|
178
|
+
f"reduction in the median residual required to keep the aligned DEM "
|
|
171
179
|
f"on disk; below this, the aligned DEM is removed.\n\n"
|
|
172
|
-
f"
|
|
173
|
-
f"
|
|
174
|
-
f"
|
|
175
|
-
f"
|
|
176
|
-
f"
|
|
177
|
-
f"
|
|
178
|
-
f" -
|
|
179
|
-
f"
|
|
180
|
-
f" -
|
|
180
|
+
f"Error Statistics (above, in meters): each row is one "
|
|
181
|
+
f"statistic of the DEM-vs-{instrument} absolute height "
|
|
182
|
+
f"residuals, Before and After alignment; Change is the "
|
|
183
|
+
f"percent change from Before to After, so negative is an "
|
|
184
|
+
f"improvement.\n"
|
|
185
|
+
f" - Median: median residual.\n"
|
|
186
|
+
f" - NMAD: normalized median absolute deviation; a robust "
|
|
187
|
+
f"spread, insensitive to outliers.\n"
|
|
188
|
+
f" - RMSE: root-mean-square residual; outlier-sensitive, so "
|
|
189
|
+
f"a large RMSE next to a small NMAD flags a tail of gross "
|
|
190
|
+
f"errors rather than a broad misfit.\n"
|
|
191
|
+
f" - p16 / p50 / p84: shown instead of the above when the "
|
|
192
|
+
f"pc_align log predates ASP 3.7.0 (which added the Median / "
|
|
193
|
+
f"NMAD / RMSE summary): 16th / 50th / 84th percentile of the "
|
|
194
|
+
f"residuals; p50 is the median.\n\n"
|
|
195
|
+
f"Translation (above, in meters): the applied translation "
|
|
196
|
+
f"vector as North / East / Down components and its "
|
|
197
|
+
f"magnitude |T|. Positive Down means the DEM was too high."
|
|
181
198
|
)
|
|
@@ -380,8 +380,27 @@ def _build_detailed_hillshade(ctx: ReportContext) -> List[object]:
|
|
|
380
380
|
]
|
|
381
381
|
|
|
382
382
|
|
|
383
|
+
# Which pc_align_report() error statistics the alignment report page shows.
|
|
384
|
+
# ASP >= 3.7.0 logs carry a Mean/StdDev/RMSE/Median/NMAD summary, and the
|
|
385
|
+
# page shows Median/NMAD/RMSE from it (mean/stddev tell the same outlier
|
|
386
|
+
# story RMSE does). Older logs only have the 16/50/84 percentiles, so the
|
|
387
|
+
# page falls back to those -- their absence is the ASP-version signal, no
|
|
388
|
+
# version sniffing needed. Everything parsed stays in alignment_report_df.
|
|
389
|
+
_ALIGNMENT_STATS_PAGE_NEW = ("median", "nmad", "rmse")
|
|
390
|
+
_ALIGNMENT_STATS_PAGE_OLD = ("p16", "p50", "p84")
|
|
391
|
+
_ALIGNMENT_STATS_PAGE_HIDDEN = ("mean", "stddev")
|
|
392
|
+
|
|
393
|
+
|
|
383
394
|
def _stats_row_from_result(align_result) -> dict:
|
|
384
|
-
"""First row of the alignment report dataframe,
|
|
395
|
+
"""First row of the alignment report dataframe, reduced to the columns
|
|
396
|
+
the alignment page shows, minus the 'key' column.
|
|
397
|
+
|
|
398
|
+
Columns are the before-alignment error stats, the same after
|
|
399
|
+
alignment, then the N-E-D translation and its magnitude. If any of the ASP >= 3.7.0
|
|
400
|
+
``median_*``/``nmad_*``/``rmse_*`` columns are present the percentile
|
|
401
|
+
columns are dropped; otherwise (ASP < 3.7.0 log) the percentiles are
|
|
402
|
+
shown as before. ``mean_*``/``stddev_*`` never appear on the page.
|
|
403
|
+
"""
|
|
385
404
|
stats_row: dict = {}
|
|
386
405
|
if (
|
|
387
406
|
align_result.alignment_report_df is not None
|
|
@@ -389,7 +408,23 @@ def _stats_row_from_result(align_result) -> dict:
|
|
|
389
408
|
):
|
|
390
409
|
row = align_result.alignment_report_df.iloc[0].to_dict()
|
|
391
410
|
row.pop("key", None)
|
|
392
|
-
|
|
411
|
+
has_new = any(k.split("_")[0] in _ALIGNMENT_STATS_PAGE_NEW for k in row)
|
|
412
|
+
shown = _ALIGNMENT_STATS_PAGE_NEW if has_new else _ALIGNMENT_STATS_PAGE_OLD
|
|
413
|
+
hidden = _ALIGNMENT_STATS_PAGE_HIDDEN + (
|
|
414
|
+
_ALIGNMENT_STATS_PAGE_OLD if has_new else ()
|
|
415
|
+
)
|
|
416
|
+
# Error stats first (before, then after alignment, in the order the
|
|
417
|
+
# tuples list them), then whatever else the row carries -- the
|
|
418
|
+
# translation columns -- in dataframe order.
|
|
419
|
+
ordered = [f"{stat}_{when}" for when in ("beg", "end") for stat in shown]
|
|
420
|
+
stats_row = {k: row[k] for k in ordered if k in row}
|
|
421
|
+
stats_row.update(
|
|
422
|
+
{
|
|
423
|
+
k: v
|
|
424
|
+
for k, v in row.items()
|
|
425
|
+
if k not in stats_row and k.split("_")[0] not in hidden
|
|
426
|
+
}
|
|
427
|
+
)
|
|
393
428
|
return stats_row
|
|
394
429
|
|
|
395
430
|
|
|
@@ -96,9 +96,10 @@ class StereoFiles:
|
|
|
96
96
|
align_left_fn, align_right_fn : str or None
|
|
97
97
|
Alignment transform text files.
|
|
98
98
|
match_point_fn : str or None
|
|
99
|
-
Match-point file
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
Match-point file, binary ``.match`` or plain-text ``.txt`` (the
|
|
100
|
+
non-``-disp-`` one when several exist); None when the directory has
|
|
101
|
+
none at the top level (e.g. a multi-view run, whose match files live
|
|
102
|
+
in the ``run-pair*/`` subdirectories).
|
|
102
103
|
left_vwip_fn, right_vwip_fn : str or None
|
|
103
104
|
Per-image raw interest point files (``.vwip``) for the left and right
|
|
104
105
|
images; either may be None (they are intermediates that ASP runs
|
|
@@ -263,15 +264,65 @@ class StereoFiles:
|
|
|
263
264
|
def _find_match_file(directory, quiet=False):
|
|
264
265
|
"""The directory's match file, or None.
|
|
265
266
|
|
|
267
|
+
ASP writes matches either as binary ``<A>__<B>.match`` or, when run
|
|
268
|
+
with ``--matches-as-txt`` (ASP >= 3.7.0), as plain-text
|
|
269
|
+
``<A>__<B>.txt`` (issue #147); both are candidates. The ``.txt`` glob
|
|
270
|
+
is anchored on the ``__`` image-name separator, and a candidate must
|
|
271
|
+
also open with a match row (:meth:`_opens_with_match_row`), so the
|
|
272
|
+
logs and alignment matrices sharing that extension are never picked
|
|
273
|
+
up -- not even when the run prefix itself contains ``__``.
|
|
274
|
+
|
|
266
275
|
There may be multiple match files if stereo was run with
|
|
267
276
|
``--num-matches-from-disparity``; in that case, filter out the match
|
|
268
277
|
file with ``-disp-`` in the filename. Candidates are sorted so the
|
|
269
|
-
choice is deterministic (glob order is filesystem-dependent)
|
|
278
|
+
choice is deterministic (glob order is filesystem-dependent), binary
|
|
279
|
+
ahead of text. The two coexist only when a binary run's matches were
|
|
280
|
+
converted for inspection (same points either way) or a
|
|
281
|
+
``--matches-as-txt`` re-run left an older ``.match`` behind; a run
|
|
282
|
+
reads one format and ignores the other, so the directory cannot say
|
|
283
|
+
which was used, and preferring binary keeps the choice every
|
|
284
|
+
pre-existing layout made.
|
|
270
285
|
"""
|
|
271
|
-
|
|
272
|
-
|
|
286
|
+
candidates = glob_file(directory, "*.match", all_files=True, quiet=True) or []
|
|
287
|
+
candidates += [
|
|
288
|
+
f
|
|
289
|
+
for f in glob_file(directory, "*__*.txt", all_files=True, quiet=True) or []
|
|
290
|
+
if StereoFiles._opens_with_match_row(f)
|
|
291
|
+
]
|
|
292
|
+
non_disp = sorted(
|
|
293
|
+
(f for f in candidates if "-disp-" not in f),
|
|
294
|
+
key=lambda f: (not f.endswith(".match"), f),
|
|
295
|
+
)
|
|
296
|
+
if not non_disp and not quiet:
|
|
297
|
+
logger.warning(
|
|
298
|
+
f"Could not find a match file (*.match or *__*.txt) in {directory}. Some plots may be missing."
|
|
299
|
+
)
|
|
273
300
|
return non_disp[0] if non_disp else None
|
|
274
301
|
|
|
302
|
+
@staticmethod
|
|
303
|
+
def _opens_with_match_row(path):
|
|
304
|
+
"""Whether a text file's first non-blank line is a plain-text match
|
|
305
|
+
row: six numeric fields (``x1 y1 unc1 x2 y2 unc2``).
|
|
306
|
+
|
|
307
|
+
With a run prefix containing ``__`` (``-o my__run``), the run's logs
|
|
308
|
+
(``my__run-log-stereo_corr-*.txt``) and alignment matrices
|
|
309
|
+
(``my__run-align-L.txt``, three fields per row) match the
|
|
310
|
+
``*__*.txt`` discovery glob too, and the alignment matrix even sorts
|
|
311
|
+
ahead of the real match file; neither opens with six numbers. An
|
|
312
|
+
empty file -- a run that found no matches -- is accepted.
|
|
313
|
+
"""
|
|
314
|
+
with open(path, errors="replace") as f:
|
|
315
|
+
fields = next((line.split() for line in f if line.strip()), None)
|
|
316
|
+
if fields is None:
|
|
317
|
+
return True
|
|
318
|
+
if len(fields) != 6:
|
|
319
|
+
return False
|
|
320
|
+
try:
|
|
321
|
+
[float(v) for v in fields]
|
|
322
|
+
except ValueError:
|
|
323
|
+
return False
|
|
324
|
+
return True
|
|
325
|
+
|
|
275
326
|
@staticmethod
|
|
276
327
|
def _find_vwip_files(directory, match_point_fn):
|
|
277
328
|
"""The directory's left and right ``.vwip`` files, or None for each.
|
|
@@ -531,13 +582,51 @@ class StereoPlotter(Plotter):
|
|
|
531
582
|
iprec.extend(desc)
|
|
532
583
|
return iprec
|
|
533
584
|
|
|
585
|
+
@staticmethod
|
|
586
|
+
def _is_text_match_file(match_point_fn):
|
|
587
|
+
"""Whether a match file is ASP's plain-text format rather than binary.
|
|
588
|
+
|
|
589
|
+
ASP itself goes by extension (``.txt`` with ``--matches-as-txt``,
|
|
590
|
+
``.match`` otherwise), but the bytes are unambiguous and survive a
|
|
591
|
+
renamed file: the binary format opens with two little-endian uint64
|
|
592
|
+
interest point counts, whose upper bytes are NUL, while the text
|
|
593
|
+
format is digits, spaces and newlines throughout (issue #147).
|
|
594
|
+
"""
|
|
595
|
+
with open(match_point_fn, "rb") as match_file:
|
|
596
|
+
return b"\x00" not in match_file.read(16)
|
|
597
|
+
|
|
598
|
+
@staticmethod
|
|
599
|
+
def _read_text_match_file(match_point_fn):
|
|
600
|
+
"""Read an ASP plain-text match file into an x1/y1/x2/y2 DataFrame.
|
|
601
|
+
|
|
602
|
+
The format (ASP >= 3.7.0; written by ``--matches-as-txt`` runs and
|
|
603
|
+
``ipmatch --binary-to-txt``) is one match per line, six
|
|
604
|
+
space-separated floats::
|
|
605
|
+
|
|
606
|
+
x1 y1 unc1 x2 y2 unc2
|
|
607
|
+
|
|
608
|
+
pixel coordinates (column, row from 0) in the first and second image
|
|
609
|
+
and a per-point uncertainty in pixels that bundle adjustment weights
|
|
610
|
+
by. The uncertainties are dropped so the result has the same columns
|
|
611
|
+
as a binary match file's; an empty file (no matches) yields an empty
|
|
612
|
+
DataFrame.
|
|
613
|
+
"""
|
|
614
|
+
df = pd.read_csv(
|
|
615
|
+
match_point_fn,
|
|
616
|
+
sep=r"\s+",
|
|
617
|
+
header=None,
|
|
618
|
+
names=["x1", "y1", "unc1", "x2", "y2", "unc2"],
|
|
619
|
+
)
|
|
620
|
+
return df[["x1", "y1", "x2", "y2"]]
|
|
621
|
+
|
|
534
622
|
def get_match_point_df(self, match_point_fn=None):
|
|
535
623
|
"""
|
|
536
|
-
|
|
624
|
+
Read a match file into a DataFrame of match points.
|
|
537
625
|
|
|
538
|
-
Reads the
|
|
539
|
-
|
|
540
|
-
|
|
626
|
+
Reads the match file produced by ASP stereo processing -- the binary
|
|
627
|
+
``.match`` format or the plain-text format ASP writes with
|
|
628
|
+
``--matches-as-txt`` (issue #147) -- and converts it to a DataFrame
|
|
629
|
+
containing matched interest points from the left and right images.
|
|
541
630
|
|
|
542
631
|
Parameters
|
|
543
632
|
----------
|
|
@@ -554,16 +643,23 @@ class StereoPlotter(Plotter):
|
|
|
554
643
|
|
|
555
644
|
Notes
|
|
556
645
|
-----
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
646
|
+
The format is detected from the file's bytes rather than its
|
|
647
|
+
extension (:meth:`_is_text_match_file`). A binary match file is
|
|
648
|
+
converted to a CSV file with the same base name but '.csv' extension,
|
|
649
|
+
then that CSV file is read into a DataFrame; if the CSV file already
|
|
650
|
+
exists, it is read directly. A plain-text match file is read directly
|
|
651
|
+
and never consults that cache, so a stale CSV left by an earlier
|
|
652
|
+
binary run next to it cannot shadow it.
|
|
560
653
|
"""
|
|
561
654
|
if match_point_fn is None:
|
|
562
655
|
match_point_fn = self.match_point_fn
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
)
|
|
566
|
-
|
|
656
|
+
if not match_point_fn:
|
|
657
|
+
return None
|
|
658
|
+
if self._is_text_match_file(match_point_fn):
|
|
659
|
+
return self._read_text_match_file(match_point_fn)
|
|
660
|
+
|
|
661
|
+
out_csv = os.path.splitext(match_point_fn)[0] + ".csv"
|
|
662
|
+
if not os.path.exists(out_csv):
|
|
567
663
|
with (
|
|
568
664
|
open(match_point_fn, "rb") as match_file,
|
|
569
665
|
open(out_csv, "w") as out,
|
|
@@ -580,11 +676,7 @@ class StereoPlotter(Plotter):
|
|
|
580
676
|
)
|
|
581
677
|
)
|
|
582
678
|
|
|
583
|
-
return (
|
|
584
|
-
pd.read_csv(out_csv, delimiter=r"\s+")
|
|
585
|
-
if out_csv and os.path.exists(out_csv)
|
|
586
|
-
else None
|
|
587
|
-
)
|
|
679
|
+
return pd.read_csv(out_csv, sep=r"\s+")
|
|
588
680
|
|
|
589
681
|
def get_vwip_df(self, vwip_fn):
|
|
590
682
|
"""
|
|
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
|
|
File without changes
|