asp-plot 1.14.1__tar.gz → 1.15.1__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 (32) hide show
  1. {asp_plot-1.14.1 → asp_plot-1.15.1}/CHANGELOG.md +14 -0
  2. {asp_plot-1.14.1 → asp_plot-1.15.1}/PKG-INFO +1 -1
  3. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/altimetry.py +1 -1
  4. asp_plot-1.15.1/asp_plot/cli/gallery.py +126 -0
  5. asp_plot-1.15.1/asp_plot/gallery.py +426 -0
  6. {asp_plot-1.14.1 → asp_plot-1.15.1}/pyproject.toml +2 -1
  7. {asp_plot-1.14.1 → asp_plot-1.15.1}/.flake8 +0 -0
  8. {asp_plot-1.14.1 → asp_plot-1.15.1}/.github/workflows/release.yml +0 -0
  9. {asp_plot-1.14.1 → asp_plot-1.15.1}/.github/workflows/run-tests.yml +0 -0
  10. {asp_plot-1.14.1 → asp_plot-1.15.1}/.gitignore +0 -0
  11. {asp_plot-1.14.1 → asp_plot-1.15.1}/.pre-commit-config.yaml +0 -0
  12. {asp_plot-1.14.1 → asp_plot-1.15.1}/.readthedocs.yaml +0 -0
  13. {asp_plot-1.14.1 → asp_plot-1.15.1}/LICENSE +0 -0
  14. {asp_plot-1.14.1 → asp_plot-1.15.1}/README.md +0 -0
  15. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/__init__.py +0 -0
  16. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/alignment.py +0 -0
  17. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/bundle_adjust.py +0 -0
  18. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/cli/__init__.py +0 -0
  19. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/cli/asp_plot.py +0 -0
  20. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/cli/csm_camera_plot.py +0 -0
  21. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/cli/request_planetary_altimetry.py +0 -0
  22. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/cli/stereo_geom.py +0 -0
  23. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/csm_camera.py +0 -0
  24. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/processing_parameters.py +0 -0
  25. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/report.py +0 -0
  26. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/scenes.py +0 -0
  27. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/stereo.py +0 -0
  28. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/stereo_geometry.py +0 -0
  29. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/stereopair_metadata_parser.py +0 -0
  30. {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/utils.py +0 -0
  31. {asp_plot-1.14.1 → asp_plot-1.15.1}/conda-forge-recipe/meta.yaml +0 -0
  32. {asp_plot-1.14.1 → asp_plot-1.15.1}/environment.yml +0 -0
@@ -5,6 +5,20 @@ 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
+ ## [1.15.1] - 2026-06-10
9
+
10
+ ### Fixed
11
+ - **`Altimetry.to_csv_for_pc_align()` wrote its CSV to the current working directory instead of the run directory.** Running the `asp_plot` CLI (or `Altimetry.align_and_evaluate()`) from a directory other than the dataset directory left a stray `atl06sr_for_pc_align_<key>.csv` in the cwd. The output path is now rooted at `self.directory` via `os.path.join()`, matching every other output in the class (`_save_to_parquet`, the `pc_align` outputs, and the planetary twin `to_csv_for_pc_align_planetary()`). No consumer changes were needed — the single internal caller (`align_and_evaluate()`) uses the return value directly, and `Alignment.pc_align_dem_to_atl06sr()` handles the directory-prefixed path unchanged.
12
+
13
+ ## [1.15.0] - 2026-06-09
14
+
15
+ ### Added
16
+ - **Gallery plotting for many DEM outputs** ([#11](https://github.com/uw-cryo/asp_plot/issues/11)). New `GalleryPlotter` class (`asp_plot/gallery.py`) and `gallery` CLI tool that lay out a *stack* of DEMs as a grid of thumbnails sharing a single global percentile color stretch and one shared colorbar — useful for QA'ing multi-date / multi-pair ASP output at a glance. Brings the legacy `original_code/gallery.py` into the modular package, dropping its `pygeotools` / `imview` dependencies in favor of the existing `Raster`, `Plotter`, and `ColorBar` utilities.
17
+ - DEMs are rendered with the package's standard convention (gray hillshade underlay + semi-transparent `viridis` DEM); the hillshade underlay is on by default and can be disabled with `--no-hillshade`.
18
+ - The layout sizes each panel to the rasters' aspect ratio and places panels with absolute positioning, so galleries of 1 to N rasters (including non-square ones) pack tightly without stray whitespace. Per-panel titles use the full filename, auto-shrunk (by measuring rendered text width) to fit the panel.
19
+ - Output resolution is matched to the rendered detail for crisp zooming, with an automatic dpi cap that keeps the PNG under `--max_filesize_mb` (default 10) regardless of the number of rasters.
20
+ - `GalleryPlotter.from_directory(directory, pattern="*-DEM.tif")` resolves a directory + glob into the raster list; the CLI also accepts an explicit list of files.
21
+
8
22
  ## [1.14.1] - 2026-06-05
9
23
 
10
24
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asp_plot
3
- Version: 1.14.1
3
+ Version: 1.15.1
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
@@ -1012,7 +1012,7 @@ class Altimetry:
1012
1012
  CSV format with columns for longitude, latitude, and height.
1013
1013
  """
1014
1014
  atl06sr = self.atl06sr_processing_levels_filtered[key].to_crs("EPSG:4326")
1015
- csv_fn = f"{filename_prefix}_{key}.csv"
1015
+ csv_fn = os.path.join(self.directory, f"{filename_prefix}_{key}.csv")
1016
1016
  df = atl06sr[["geometry", "h_mean"]].copy()
1017
1017
  df["lon"] = df["geometry"].x
1018
1018
  df["lat"] = df["geometry"].y
@@ -0,0 +1,126 @@
1
+ import os
2
+
3
+ import click
4
+
5
+ from asp_plot.gallery import GalleryPlotter
6
+
7
+
8
+ @click.command()
9
+ @click.option(
10
+ "--directory",
11
+ prompt=False,
12
+ default="./",
13
+ help="Directory to search for rasters. Default: current directory.",
14
+ )
15
+ @click.option(
16
+ "--pattern",
17
+ prompt=False,
18
+ default="*-DEM.tif",
19
+ help="Glob pattern for rasters within the directory; recursive '**' matches subdirectories (e.g. '**/*-DEM.tif'). Default: '*-DEM.tif'.",
20
+ )
21
+ @click.argument("files", nargs=-1, type=click.Path(exists=True))
22
+ @click.option(
23
+ "--hillshade/--no-hillshade",
24
+ default=True,
25
+ help="Draw a gray hillshade underlay beneath each DEM. Default: True.",
26
+ )
27
+ @click.option(
28
+ "--cmap",
29
+ prompt=False,
30
+ default="viridis",
31
+ help="Colormap for the DEMs. Default: viridis.",
32
+ )
33
+ @click.option(
34
+ "--downsample",
35
+ prompt=False,
36
+ default="auto",
37
+ help="Downsample factor for reads, or 'auto' to size thumbnails automatically. Default: auto.",
38
+ )
39
+ @click.option(
40
+ "--max_filesize_mb",
41
+ prompt=False,
42
+ default=10.0,
43
+ type=float,
44
+ help="Soft cap on the output PNG size in MB; auto dpi is reduced to respect it. Default: 10.",
45
+ )
46
+ @click.option(
47
+ "--title",
48
+ prompt=False,
49
+ default=None,
50
+ help="Figure suptitle. Default: none.",
51
+ )
52
+ @click.option(
53
+ "--output_directory",
54
+ prompt=False,
55
+ default=None,
56
+ help="Directory to save the output plot. Default: Input directory.",
57
+ )
58
+ @click.option(
59
+ "--output_filename",
60
+ prompt=False,
61
+ default=None,
62
+ help="Filename for the output plot. Default: Directory name with _gallery.png suffix.",
63
+ )
64
+ def main(
65
+ directory,
66
+ pattern,
67
+ files,
68
+ hillshade,
69
+ cmap,
70
+ downsample,
71
+ max_filesize_mb,
72
+ title,
73
+ output_directory,
74
+ output_filename,
75
+ ):
76
+ """
77
+ Generate a gallery figure of many DEMs sharing a single color scale.
78
+
79
+ Provide either a --directory (searched with --pattern) or an explicit list
80
+ of FILES. Explicit files take precedence over the directory + pattern.
81
+ """
82
+ directory = os.path.expanduser(directory)
83
+ if output_directory:
84
+ output_directory = os.path.expanduser(output_directory)
85
+
86
+ # "auto" downsample passes through as a string; an explicit integer is cast.
87
+ if downsample != "auto":
88
+ downsample = int(downsample)
89
+
90
+ if files:
91
+ print(f"\nBuilding gallery from {len(files)} explicit file(s)\n")
92
+ plotter = GalleryPlotter(sorted(files), downsample=downsample, title=title)
93
+ # Default output location is the directory of the first file.
94
+ default_dir = os.path.dirname(os.path.abspath(files[0]))
95
+ dir_name = os.path.split(default_dir.rstrip("/\\"))[-1]
96
+ else:
97
+ print(f"\nBuilding gallery from '{pattern}' in {directory}\n")
98
+ plotter = GalleryPlotter.from_directory(
99
+ directory, pattern=pattern, downsample=downsample, title=title
100
+ )
101
+ default_dir = directory
102
+ dir_name = os.path.split(directory.rstrip("/\\"))[-1]
103
+
104
+ if output_directory is None:
105
+ output_directory = default_dir
106
+
107
+ if output_filename is None:
108
+ output_filename = f"{dir_name}_gallery.png"
109
+
110
+ os.makedirs(output_directory, exist_ok=True)
111
+
112
+ plotter.plot_gallery(
113
+ hillshade=hillshade,
114
+ cmap=cmap,
115
+ max_filesize_mb=max_filesize_mb,
116
+ save_dir=output_directory,
117
+ fig_fn=output_filename,
118
+ )
119
+
120
+ print(
121
+ f"\nGallery plot saved to {os.path.join(output_directory, output_filename)}\n"
122
+ )
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
@@ -0,0 +1,426 @@
1
+ import glob
2
+ import logging
3
+ import os
4
+
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+ from osgeo import gdal
8
+
9
+ from asp_plot.utils import Plotter, Raster, save_figure
10
+
11
+ logging.basicConfig(level=logging.WARNING)
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Target longest-edge size (pixels) for "auto" downsampling of gallery thumbnails.
15
+ # Full-resolution ASP DEMs are often 15k+ pixels and hundreds of MB. This sets
16
+ # how much detail each thumbnail preserves; the save dpi is matched to it so one
17
+ # source pixel maps to ~one rendered pixel (crisp when zoomed) without bloating
18
+ # the file. 1200 px keeps a 1-to-N-panel gallery comfortably under ~10 MB.
19
+ GALLERY_TARGET_PX = 1200
20
+
21
+
22
+ class GalleryPlotter(Plotter):
23
+ """
24
+ Plot a grid of DEM thumbnails sharing a single color scale.
25
+
26
+ This class extends the base Plotter class to visualize a *stack* of
27
+ rasters (e.g. multi-date or multi-pair ASP DEMs over the same area) as a
28
+ gallery of thumbnails. All panels share a single global percentile color
29
+ stretch and one shared colorbar, which makes it easy to QA elevation
30
+ differences across many outputs at a glance.
31
+
32
+ DEMs are rendered using the same convention as the rest of the package
33
+ (gray hillshade underlay with a semi-transparent ``viridis`` DEM on top).
34
+
35
+ The layout sizes every panel to the rasters' actual aspect ratio and places
36
+ panels with absolute positioning, so a gallery of 1 to N rasters (including
37
+ non-square ones) packs tightly without stray whitespace. Per-panel titles
38
+ use the full filename, auto-shrunk to fit the panel width.
39
+
40
+ Attributes
41
+ ----------
42
+ raster_list : list of str
43
+ Resolved list of raster file paths to plot.
44
+ downsample : int or str
45
+ Downsampling factor for reads, or "auto" to pick per-raster factors
46
+ so the longest edge is ~``GALLERY_TARGET_PX`` pixels.
47
+ title : str or None
48
+ Figure suptitle, inherited from the Plotter class.
49
+
50
+ Examples
51
+ --------
52
+ >>> gallery = GalleryPlotter.from_directory("/path/to/dems", pattern="*-DEM.tif")
53
+ >>> gallery.plot_gallery(save_dir="/path/to/output", fig_fn="dem_gallery.png")
54
+ """
55
+
56
+ def __init__(self, raster_list, downsample="auto", **kwargs):
57
+ """
58
+ Initialize the GalleryPlotter object.
59
+
60
+ Parameters
61
+ ----------
62
+ raster_list : list of str
63
+ List of raster file paths to plot. Use the ``from_directory``
64
+ classmethod to resolve a directory + glob pattern into this list.
65
+ downsample : int or str, optional
66
+ Downsampling factor passed to ``Raster``. "auto" (default) picks a
67
+ per-raster factor so the longest edge is ~``GALLERY_TARGET_PX``.
68
+ **kwargs : dict, optional
69
+ Additional keyword arguments passed to the Plotter base class,
70
+ particularly 'title' for the figure suptitle.
71
+
72
+ Raises
73
+ ------
74
+ ValueError
75
+ If ``raster_list`` is empty.
76
+ """
77
+ super().__init__(**kwargs)
78
+ self.raster_list = [os.path.expanduser(fn) for fn in raster_list]
79
+ if not self.raster_list:
80
+ raise ValueError(
81
+ "\n\nNo rasters to plot. Provide a non-empty list of files, "
82
+ "or check the directory and pattern.\n\n"
83
+ )
84
+ self.downsample = downsample
85
+
86
+ @classmethod
87
+ def from_directory(cls, directory, pattern="*-DEM.tif", **kwargs):
88
+ """
89
+ Build a GalleryPlotter by globbing a directory for rasters.
90
+
91
+ Globbing is recursive, so ``**`` in the pattern descends into
92
+ subdirectories. This is handy for typical ASP layouts where each pair
93
+ lives in its own subdirectory:
94
+
95
+ - ``"*-DEM.tif"`` — top-level only
96
+ - ``"*/*-DEM.tif"`` — exactly one subdirectory deep
97
+ - ``"**/*-DEM.tif"`` — any depth (recursive)
98
+
99
+ Parameters
100
+ ----------
101
+ directory : str
102
+ Directory to search for rasters.
103
+ pattern : str, optional
104
+ Glob pattern for the rasters, default is "*-DEM.tif". Supports
105
+ recursive ``**`` to match nested subdirectories.
106
+ **kwargs : dict, optional
107
+ Additional keyword arguments passed to ``__init__`` (e.g.
108
+ ``downsample``, ``title``).
109
+
110
+ Returns
111
+ -------
112
+ GalleryPlotter
113
+ Plotter initialized with the sorted list of matching files.
114
+
115
+ Raises
116
+ ------
117
+ ValueError
118
+ If no files in ``directory`` match ``pattern``.
119
+ """
120
+ directory = os.path.expanduser(directory)
121
+ matches = sorted(glob.glob(os.path.join(directory, pattern), recursive=True))
122
+ if not matches:
123
+ raise ValueError(
124
+ f"\n\nNo files matching '{pattern}' found in {directory}.\n\n"
125
+ )
126
+ return cls(matches, **kwargs)
127
+
128
+ @staticmethod
129
+ def _grid_shape(n, aspect=1.0):
130
+ """
131
+ Compute the (nrows, ncols) grid for ``n`` panels of a given aspect.
132
+
133
+ Searches all column counts and picks the one whose overall grid is
134
+ closest to square in display space (accounting for each panel's
135
+ width/height ``aspect``), lightly penalizing empty trailing cells.
136
+ This keeps galleries of 1 to N panels — square or not — visually
137
+ balanced.
138
+
139
+ Parameters
140
+ ----------
141
+ n : int
142
+ Number of panels.
143
+ aspect : float, optional
144
+ Panel width / height ratio, default 1.0 (square).
145
+
146
+ Returns
147
+ -------
148
+ tuple of int
149
+ (nrows, ncols)
150
+ """
151
+ best = None
152
+ for ncols in range(1, n + 1):
153
+ nrows = int(np.ceil(n / ncols))
154
+ display_aspect = (ncols * aspect) / nrows
155
+ empty = nrows * ncols - n
156
+ # log() so e.g. 2:1 and 1:2 are penalized equally around square.
157
+ cost = abs(np.log(display_aspect)) + 0.1 * empty
158
+ if best is None or cost < best[0]:
159
+ best = (cost, (nrows, ncols))
160
+ return best[1]
161
+
162
+ @staticmethod
163
+ def _fit_title_fontsize(text, panel_w_in, max_fs=8.0, min_fs=4.0):
164
+ """
165
+ Pick a font size so ``text`` fits within ``panel_w_in`` inches.
166
+
167
+ Uses an average character-width estimate (~0.58 * fontsize in points)
168
+ rather than a renderer, so it works headlessly and deterministically.
169
+
170
+ Parameters
171
+ ----------
172
+ text : str
173
+ The title text.
174
+ panel_w_in : float
175
+ Panel width in inches.
176
+ max_fs, min_fs : float, optional
177
+ Font-size clamps in points.
178
+
179
+ Returns
180
+ -------
181
+ float
182
+ Font size in points.
183
+ """
184
+ panel_w_pts = panel_w_in * 72.0
185
+ fs = panel_w_pts / (0.58 * max(len(text), 1))
186
+ return float(np.clip(fs, min_fs, max_fs))
187
+
188
+ def _fit_titles(self, fig, title_artists, panel_w_in, min_fs=3.5, pad=0.92):
189
+ """
190
+ Shrink each title's font so its *rendered* width fits the panel.
191
+
192
+ Measures the real text extent with an Agg renderer (accurate for any
193
+ font, unlike a character-count estimate), then scales the font size by
194
+ the width ratio. Falls back to the heuristic ``_fit_title_fontsize`` if
195
+ measurement fails for any reason (e.g. an unusual backend).
196
+
197
+ Parameters
198
+ ----------
199
+ fig : matplotlib.figure.Figure
200
+ The figure (used for its dpi).
201
+ title_artists : list of matplotlib.text.Text
202
+ The per-panel title artists to resize in place.
203
+ panel_w_in : float
204
+ Panel width in inches.
205
+ min_fs : float, optional
206
+ Lower clamp on the font size, default 3.5.
207
+ pad : float, optional
208
+ Fraction of the panel width to fill (leaves a small margin).
209
+ """
210
+ panel_w_px = panel_w_in * fig.dpi
211
+ try:
212
+ from matplotlib.backends.backend_agg import RendererAgg
213
+
214
+ renderer = RendererAgg(
215
+ int(fig.get_figwidth() * fig.dpi),
216
+ int(fig.get_figheight() * fig.dpi),
217
+ fig.dpi,
218
+ )
219
+ for t in title_artists:
220
+ w = t.get_window_extent(renderer=renderer).width
221
+ if w <= 0:
222
+ continue
223
+ scaled = t.get_fontsize() * (panel_w_px / w) * pad
224
+ t.set_fontsize(float(np.clip(scaled, min_fs, t.get_fontsize())))
225
+ except Exception:
226
+ for t in title_artists:
227
+ t.set_fontsize(self._fit_title_fontsize(t.get_text(), panel_w_in))
228
+
229
+ def _resolve_downsample(self, ds):
230
+ """
231
+ Resolve the downsample factor for a single dataset.
232
+
233
+ Returns ``self.downsample`` directly if it is an integer, otherwise
234
+ ("auto") picks a factor so the longest edge is ~``GALLERY_TARGET_PX``.
235
+ """
236
+ if isinstance(self.downsample, str):
237
+ longest = max(ds.height, ds.width)
238
+ return max(1, int(np.ceil(longest / GALLERY_TARGET_PX)))
239
+ return self.downsample
240
+
241
+ def _hillshade_at(self, fn, shape):
242
+ """
243
+ Compute a GDAL hillshade for ``fn`` matching a downsampled DEM shape.
244
+
245
+ Mirrors ``Raster.hillshade()`` (GDAL ``hillshade``, computeEdges) but
246
+ runs on a downsampled in-memory copy so the underlay array matches the
247
+ downsampled DEM array in shape.
248
+
249
+ Parameters
250
+ ----------
251
+ fn : str
252
+ Path to the DEM.
253
+ shape : tuple of int
254
+ Target (height, width) of the downsampled DEM.
255
+
256
+ Returns
257
+ -------
258
+ numpy.ma.MaskedArray
259
+ Hillshade array (nodata masked at 0).
260
+ """
261
+ out_h, out_w = shape
262
+ gdal_ds = gdal.Open(fn)
263
+ mem = gdal.Translate("", gdal_ds, format="MEM", width=out_w, height=out_h)
264
+ hs_ds = gdal.DEMProcessing(
265
+ "", mem, "hillshade", format="MEM", computeEdges=True
266
+ )
267
+ return np.ma.masked_equal(hs_ds.ReadAsArray(), 0)
268
+
269
+ def plot_gallery(
270
+ self,
271
+ hillshade=True,
272
+ cmap="viridis",
273
+ clim=None,
274
+ cbar_label="Elevation (m HAE)",
275
+ panel_size=3.0,
276
+ dpi=None,
277
+ max_dpi=600,
278
+ max_filesize_mb=10.0,
279
+ save_dir=None,
280
+ fig_fn=None,
281
+ ):
282
+ """
283
+ Plot the DEM gallery.
284
+
285
+ Reads every raster (downsampled), computes a global shared color
286
+ stretch, and lays the thumbnails out in an aspect-matched grid with one
287
+ shared colorbar. Each panel is titled with its filename, auto-shrunk to
288
+ fit the panel width.
289
+
290
+ Parameters
291
+ ----------
292
+ hillshade : bool, optional
293
+ If True (default), draw a gray hillshade underlay beneath each DEM
294
+ and render the DEM semi-transparently on top, matching the
295
+ convention used elsewhere in the package.
296
+ cmap : str, optional
297
+ Colormap for the DEMs, default is "viridis".
298
+ clim : tuple or None, optional
299
+ Color limits (min, max). If None (default), a global percentile
300
+ stretch shared across all rasters is computed via ``ColorBar``.
301
+ cbar_label : str, optional
302
+ Label for the shared colorbar, default is "Elevation (m HAE)".
303
+ panel_size : float, optional
304
+ Longest-edge size of each panel in inches, default 3.0.
305
+ dpi : int or None, optional
306
+ Save resolution. If None (default), matched to the rendered detail
307
+ (so one source pixel ~ one image pixel), then lowered if needed to
308
+ respect ``max_filesize_mb`` and clamped to ``max_dpi``.
309
+ max_dpi : int, optional
310
+ Upper bound on the auto dpi to keep file size in check, default 600.
311
+ max_filesize_mb : float, optional
312
+ Soft cap on the output PNG size in MB, default 10. The auto dpi is
313
+ reduced so the total rendered pixel count stays within an estimated
314
+ budget; this keeps galleries of many rasters from exceeding the cap.
315
+ Ignored when ``dpi`` is given explicitly.
316
+ save_dir : str or None, optional
317
+ Directory to save the figure, default is None (don't save).
318
+ fig_fn : str or None, optional
319
+ Filename for the saved figure, default is None.
320
+
321
+ Returns
322
+ -------
323
+ matplotlib.figure.Figure
324
+ The gallery figure.
325
+ """
326
+ # Read every raster once (downsampled), caching arrays so we don't
327
+ # re-read the (potentially huge) source files for plotting.
328
+ arrays = []
329
+ for fn in self.raster_list:
330
+ ds_probe = Raster(fn)
331
+ downsample = self._resolve_downsample(ds_probe.ds)
332
+ arrays.append(Raster(fn, downsample=downsample).read_array())
333
+
334
+ if clim is None:
335
+ clim = self.cb.find_common_clim(arrays)
336
+
337
+ n = len(arrays)
338
+
339
+ # Panel box sized to the rasters' (median) aspect ratio so imshow's
340
+ # equal-aspect images fill each box with minimal internal whitespace.
341
+ aspects = [a.shape[1] / a.shape[0] for a in arrays]
342
+ aspect = float(np.median(aspects))
343
+ if aspect >= 1.0:
344
+ panel_w, panel_h = panel_size, panel_size / aspect
345
+ else:
346
+ panel_w, panel_h = panel_size * aspect, panel_size
347
+
348
+ nrows, ncols = self._grid_shape(n, panel_w / panel_h)
349
+
350
+ # Absolute (inches) layout so trailing empty cells leave no gap.
351
+ # Reserve vertical room for the largest possible title (titles are
352
+ # shrunk, never grown, beyond this in _fit_titles).
353
+ max_title_fs = 8.0
354
+ title_h = max_title_fs / 72.0 + 0.06
355
+ left, right, bottom = 0.12, 0.12, 0.12
356
+ top = 0.12 + (0.3 if self.title else 0.0)
357
+ wgap, hgap = 0.14, title_h + 0.06
358
+ cbar_gap, cbar_w = 0.18, 0.22
359
+
360
+ grid_w = ncols * panel_w + (ncols - 1) * wgap
361
+ grid_h = nrows * panel_h + (nrows - 1) * hgap
362
+ fig_w = left + grid_w + cbar_gap + cbar_w + right
363
+ fig_h = top + grid_h + bottom
364
+
365
+ # Match save dpi to the actual rendered detail (median longest edge),
366
+ # then lower it if needed so the total rendered pixel count stays within
367
+ # a file-size budget (keeps many-raster galleries under max_filesize_mb).
368
+ if dpi is None:
369
+ med_long_px = float(np.median([max(a.shape) for a in arrays]))
370
+ detail_dpi = round(med_long_px / panel_size)
371
+ # ~0.8 bytes/pixel is a conservative estimate for these hillshaded
372
+ # DEM PNGs; the 0.85 factor adds headroom against the soft cap.
373
+ budget_px = 0.85 * max_filesize_mb * 1e6 / 0.8
374
+ budget_dpi = (budget_px / (fig_w * fig_h)) ** 0.5
375
+ dpi = int(np.clip(min(detail_dpi, budget_dpi), 100, max_dpi))
376
+
377
+ fig = plt.figure(figsize=(fig_w, fig_h))
378
+ if self.title is not None:
379
+ fig.suptitle(self.title, size=10)
380
+
381
+ im = None
382
+ title_artists = []
383
+ for i, (fn, array) in enumerate(zip(self.raster_list, arrays)):
384
+ row, col = divmod(i, ncols)
385
+ x_in = left + col * (panel_w + wgap)
386
+ y_in = fig_h - top - row * (panel_h + hgap) - panel_h
387
+ ax = fig.add_axes(
388
+ [x_in / fig_w, y_in / fig_h, panel_w / fig_w, panel_h / fig_h]
389
+ )
390
+ if hillshade:
391
+ hs = self._hillshade_at(fn, array.shape)
392
+ self.plot_array(ax=ax, array=hs, cmap="gray", add_cbar=False)
393
+ im = self.plot_array(
394
+ ax=ax,
395
+ array=array,
396
+ clim=clim,
397
+ cmap=cmap,
398
+ add_cbar=False,
399
+ alpha=0.5,
400
+ )
401
+ else:
402
+ im = self.plot_array(
403
+ ax=ax, array=array, clim=clim, cmap=cmap, add_cbar=False
404
+ )
405
+ title_artists.append(ax.set_title(os.path.basename(fn), size=max_title_fs))
406
+
407
+ # Shrink each title so the full filename fits within its panel width.
408
+ self._fit_titles(fig, title_artists, panel_w)
409
+
410
+ # Single shared colorbar spanning the panel grid on the right.
411
+ if im is not None:
412
+ cbar_x = left + grid_w + cbar_gap
413
+ cax = fig.add_axes(
414
+ [cbar_x / fig_w, bottom / fig_h, cbar_w / fig_w, grid_h / fig_h]
415
+ )
416
+ cbar = fig.colorbar(
417
+ im,
418
+ cax=cax,
419
+ extend=self.cb.get_cbar_extend(arrays[0], clim),
420
+ )
421
+ cbar.set_label(cbar_label)
422
+
423
+ if save_dir and fig_fn:
424
+ save_figure(fig, save_dir, fig_fn, dpi=dpi)
425
+
426
+ return fig
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "asp_plot"
7
- version = "1.14.1"
7
+ version = "1.15.1"
8
8
  license = {text = "BSD-3-Clause"}
9
9
  authors = [
10
10
  { name="Ben Purinton", email="purinton@uw.edu" },
@@ -44,6 +44,7 @@ asp_plot = "asp_plot.cli.asp_plot:main"
44
44
  csm_camera_plot = "asp_plot.cli.csm_camera_plot:main"
45
45
  stereo_geom = "asp_plot.cli.stereo_geom:main"
46
46
  request_planetary_altimetry = "asp_plot.cli.request_planetary_altimetry:main"
47
+ gallery = "asp_plot.cli.gallery:main"
47
48
 
48
49
  [project.optional-dependencies]
49
50
  dev = [
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