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.
- {asp_plot-1.14.1 → asp_plot-1.15.1}/CHANGELOG.md +14 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/PKG-INFO +1 -1
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/altimetry.py +1 -1
- asp_plot-1.15.1/asp_plot/cli/gallery.py +126 -0
- asp_plot-1.15.1/asp_plot/gallery.py +426 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/pyproject.toml +2 -1
- {asp_plot-1.14.1 → asp_plot-1.15.1}/.flake8 +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/.github/workflows/release.yml +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/.github/workflows/run-tests.yml +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/.gitignore +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/.pre-commit-config.yaml +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/.readthedocs.yaml +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/LICENSE +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/README.md +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/__init__.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/alignment.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/bundle_adjust.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/cli/__init__.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/cli/asp_plot.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/cli/csm_camera_plot.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/cli/request_planetary_altimetry.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/cli/stereo_geom.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/csm_camera.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/processing_parameters.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/report.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/scenes.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/stereo.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/stereo_geometry.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/stereopair_metadata_parser.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/asp_plot/utils.py +0 -0
- {asp_plot-1.14.1 → asp_plot-1.15.1}/conda-forge-recipe/meta.yaml +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|