asp-plot 3.2.0__py3-none-any.whl → 3.4.0__py3-none-any.whl

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/alignment.py CHANGED
@@ -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)
@@ -0,0 +1,181 @@
1
+ import os
2
+
3
+ import click
4
+
5
+ from asp_plot.dem_benchmark import DEMBenchmark
6
+ from asp_plot.icesat2_source import WORLDCOVER_GROUPS
7
+
8
+
9
+ @click.command()
10
+ @click.argument("dems", nargs=-1, required=True)
11
+ @click.option(
12
+ "--parquet",
13
+ prompt=False,
14
+ default=None,
15
+ help="ICESat-2 ATL06-SR parquet cache to score Earth DEMs against (the "
16
+ "atl06sr_all.parquet a previous asp_report run wrote next to its report, "
17
+ "or from Altimetry.request_atl06sr_multi_processing(save_to_parquet=True)). "
18
+ "The same points are replayed for every DEM; no SlideRule request is made.",
19
+ )
20
+ @click.option(
21
+ "--altimetry-csv",
22
+ prompt=False,
23
+ default=None,
24
+ help="LOLA/MOLA CSV to score Moon/Mars DEMs against (see "
25
+ "request_planetary_altimetry). Use instead of --parquet for planetary DEMs.",
26
+ )
27
+ @click.option(
28
+ "--directory",
29
+ prompt=False,
30
+ default="./",
31
+ help="Working directory. pc_align products and the translated DEM copies go "
32
+ "under <directory>/dem_benchmark/<label>/, never into the DEMs' own folders. "
33
+ "Default: current directory.",
34
+ )
35
+ @click.option(
36
+ "--reference",
37
+ prompt=False,
38
+ default=None,
39
+ help="Label of one of the DEMs to difference the others against (vs_ref "
40
+ "columns of the stats table). Default: none.",
41
+ )
42
+ @click.option(
43
+ "--no-pc-align",
44
+ is_flag=True,
45
+ default=False,
46
+ help="Skip the per-DEM pc_align translation; report pre-alignment residuals only.",
47
+ )
48
+ @click.option(
49
+ "--own-extent",
50
+ is_flag=True,
51
+ default=False,
52
+ help="Compute coverage and triangulation-error statistics over each DEM's "
53
+ "own extent instead of the intersection of all DEM footprints.",
54
+ )
55
+ @click.option(
56
+ "--filter-out",
57
+ prompt=False,
58
+ multiple=True,
59
+ default=("water",),
60
+ type=click.Choice(sorted(WORLDCOVER_GROUPS) + ["none"]),
61
+ help="ESA WorldCover group to drop from the ICESat-2 points before "
62
+ "differencing; repeat the option for several. Giving the option replaces "
63
+ "the default, so name water as well to keep dropping it: '--filter-out "
64
+ "water --filter-out trees' is the stable-terrain choice when years "
65
+ "separate the imagery from the ICESat-2 passes. 'none' keeps every return "
66
+ "and cannot be combined with a group. Earth DEMs only. Default: water.",
67
+ )
68
+ @click.option(
69
+ "--n-bootstrap",
70
+ prompt=False,
71
+ default=1000,
72
+ type=int,
73
+ help="Replicates of the paired block bootstrap that puts 95 % intervals on "
74
+ "the shared-point median and NMAD of every DEM and on its NMAD difference "
75
+ "to the best DEM (whole ICESat-2 beam tracks, or MOLA orbits, are "
76
+ "resampled). 0 skips it. Default: 1000.",
77
+ )
78
+ @click.option(
79
+ "--title",
80
+ prompt=False,
81
+ default=None,
82
+ help="Figure title. Default: none.",
83
+ )
84
+ @click.option(
85
+ "--output-directory",
86
+ prompt=False,
87
+ default=None,
88
+ help="Directory for the figure and stats CSV. Default: --directory.",
89
+ )
90
+ @click.option(
91
+ "--output-filename",
92
+ prompt=False,
93
+ default="dem_benchmark.png",
94
+ help="Figure filename; the stats CSV takes the same name with a .csv "
95
+ "extension, and the residual histogram figure a _histogram suffix. "
96
+ "Default: dem_benchmark.png.",
97
+ )
98
+ def main(
99
+ dems,
100
+ parquet,
101
+ altimetry_csv,
102
+ directory,
103
+ reference,
104
+ no_pc_align,
105
+ own_extent,
106
+ filter_out,
107
+ n_bootstrap,
108
+ title,
109
+ output_directory,
110
+ output_filename,
111
+ ):
112
+ """
113
+ Score many DEMs against one altimetry sample.
114
+
115
+ DEMS are paths, optionally labelled as LABEL=PATH (e.g.
116
+ "MVS=stereo_mvs3/run-DEM.tif"); an unlabelled ASP run-DEM.tif is labelled
117
+ by its folder. Every DEM gets: coverage inside the common footprint, the
118
+ median triangulation error from its IntersectionErr raster when present
119
+ (table only; it is a consistency check, not a quality ranking), and the
120
+ altimetry-minus-DEM median / NMAD / RMSE before and (unless
121
+ --no-pc-align) after a pc_align translation, on its own points and on the
122
+ points valid in every DEM, with bootstrap intervals on the latter. Writes
123
+ a one-row-per-DEM summary figure, an overlaid residual histogram, and the
124
+ stats table as CSV.
125
+ """
126
+ if "none" in filter_out and len(set(filter_out)) > 1:
127
+ raise click.UsageError(
128
+ "--filter-out none keeps every return and cannot be combined with "
129
+ "a land-cover group."
130
+ )
131
+ directory = os.path.expanduser(directory)
132
+ if output_directory is None:
133
+ output_directory = directory
134
+ output_directory = os.path.expanduser(output_directory)
135
+
136
+ bench = DEMBenchmark(
137
+ directory=directory,
138
+ dems=list(dems),
139
+ parquet=parquet,
140
+ altimetry_csv=altimetry_csv,
141
+ reference=reference,
142
+ aoi=None if own_extent else "intersection",
143
+ filter_out=[g for g in filter_out if g != "none"] or None,
144
+ n_bootstrap=n_bootstrap,
145
+ title=title,
146
+ )
147
+ stats = bench.run(pc_align=not no_pc_align)
148
+
149
+ stem = os.path.splitext(output_filename)[0]
150
+ csv_fn = bench.save_stats(os.path.join(output_directory, f"{stem}.csv"))
151
+ bench.summary_plot(save_dir=output_directory, fig_fn=output_filename)
152
+ bench.histogram_plot(save_dir=output_directory, fig_fn=f"{stem}_histogram.png")
153
+
154
+ # The headline columns; the CSV has every column of STATS_COLUMNS.
155
+ shown = stats[
156
+ [
157
+ "label",
158
+ "valid_pct",
159
+ "ie_median_m",
160
+ "n_points",
161
+ "n_shared",
162
+ "dh_shared_median_m",
163
+ "dh_shared_nmad_m",
164
+ "dh_aligned_shared_median_m",
165
+ "dh_aligned_shared_nmad_m",
166
+ "nmad_ci_low_m",
167
+ "nmad_ci_high_m",
168
+ "p_best",
169
+ ]
170
+ ]
171
+ shown = shown.dropna(axis=1, how="all")
172
+ print("\n" + shown.to_string(index=False, float_format=lambda v: f"{v:.2f}"))
173
+ print(
174
+ f"\nSummary figure: {os.path.join(output_directory, output_filename)}"
175
+ f"\nHistogram: {os.path.join(output_directory, stem + '_histogram.png')}"
176
+ f"\nStats table: {csv_fn}\n"
177
+ )
178
+
179
+
180
+ if __name__ == "__main__":
181
+ main()