asp-plot 1.5.0__tar.gz → 1.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {asp_plot-1.5.0 → asp_plot-1.6.0}/.claude/settings.local.json +11 -1
  2. {asp_plot-1.5.0 → asp_plot-1.6.0}/CHANGELOG.md +19 -0
  3. {asp_plot-1.5.0 → asp_plot-1.6.0}/PKG-INFO +2 -2
  4. {asp_plot-1.5.0 → asp_plot-1.6.0}/README.md +1 -1
  5. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/cli/asp_plot.py +174 -52
  6. asp_plot-1.6.0/asp_plot/report.py +296 -0
  7. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/utils.py +0 -94
  8. {asp_plot-1.5.0 → asp_plot-1.6.0}/environment.yml +1 -2
  9. {asp_plot-1.5.0 → asp_plot-1.6.0}/pyproject.toml +1 -1
  10. {asp_plot-1.5.0 → asp_plot-1.6.0}/.flake8 +0 -0
  11. {asp_plot-1.5.0 → asp_plot-1.6.0}/.github/workflows/release.yml +0 -0
  12. {asp_plot-1.5.0 → asp_plot-1.6.0}/.github/workflows/run-tests.yml +0 -0
  13. {asp_plot-1.5.0 → asp_plot-1.6.0}/.gitignore +0 -0
  14. {asp_plot-1.5.0 → asp_plot-1.6.0}/.pre-commit-config.yaml +0 -0
  15. {asp_plot-1.5.0 → asp_plot-1.6.0}/LICENSE +0 -0
  16. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/__init__.py +0 -0
  17. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/alignment.py +0 -0
  18. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/altimetry.py +0 -0
  19. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/bundle_adjust.py +0 -0
  20. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/cli/__init__.py +0 -0
  21. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/cli/csm_camera_plot.py +0 -0
  22. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/cli/stereo_geom.py +0 -0
  23. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/csm_camera.py +0 -0
  24. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/processing_parameters.py +0 -0
  25. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/scenes.py +0 -0
  26. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/stereo.py +0 -0
  27. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/stereo_geometry.py +0 -0
  28. {asp_plot-1.5.0 → asp_plot-1.6.0}/asp_plot/stereopair_metadata_parser.py +0 -0
@@ -16,7 +16,17 @@
16
16
  "WebFetch(domain:dg-cms-uploads-production.s3.amazonaws.com)",
17
17
  "WebFetch(domain:engineering.purdue.edu)",
18
18
  "Bash(gh api:*)",
19
- "Bash(gh run view:*)"
19
+ "Bash(gh run view:*)",
20
+ "Bash(gh issue view:*)",
21
+ "WebFetch(domain:github.com)",
22
+ "WebFetch(domain:pypi.org)",
23
+ "Bash(find:*)",
24
+ "WebFetch(domain:py-pdf.github.io)",
25
+ "Bash(conda install:*)",
26
+ "Bash(mamba install:*)",
27
+ "Bash(python:*)",
28
+ "Bash(pip install:*)",
29
+ "Bash(pre-commit run:*)"
20
30
  ],
21
31
  "deny": [],
22
32
  "ask": []
@@ -5,6 +5,25 @@ 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.6.0] - 2026-02-16
9
+
10
+ ### Added
11
+ - Structured PDF report generation with title page, section headings, figure captions, DEM metadata summary table, and runtime summary table
12
+ - New `report.py` module containing `ReportSection` and `ReportMetadata` dataclasses, `ASPReportPDF` class, and `compile_report()` function
13
+ - DEM metadata (dimensions, GSD, CRS, nodata %, elevation range) automatically collected and displayed on the report title page
14
+ - Figure captions describing each plot in the generated PDF report
15
+ - Page headers (report title) and footers (page numbers) throughout the report
16
+ - Tests for report dataclasses and PDF compilation (8 new tests)
17
+
18
+ ### Changed
19
+ - Replaced `markdown-pdf` dependency with `fpdf2` (available on conda-forge, enabling conda-only installation)
20
+ - Reordered report sections: Input Scenes and Stereo Geometry now appear before DEM results, matching the logical processing flow
21
+ - Report generation moved from `utils.py` to dedicated `report.py` module
22
+ - PNG images are now embedded directly in the PDF (eliminated intermediate PNG-to-JPEG conversion step)
23
+
24
+ ### Removed
25
+ - Dependency on `markdown-pdf` (pip-only package that blocked conda-forge packaging)
26
+
8
27
  ## [1.5.0] - 2026-02-13
9
28
 
10
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asp_plot
3
- Version: 1.5.0
3
+ Version: 1.6.0
4
4
  Summary: Package for plotting outputs Ames Stereo Pipeline processing
5
5
  Project-URL: Homepage, https://github.com/uw-cryo/asp_plot
6
6
  Project-URL: Issues, https://github.com/uw-cryo/asp_plot/issues
@@ -20,7 +20,7 @@ Description-Content-Type: text/markdown
20
20
  Scripts and notebooks to visualize output from the [NASA Ames Stereo Pipeline (ASP)](https://github.com/NeoGeographyToolkit/StereoPipeline).
21
21
 
22
22
  > [!IMPORTANT]
23
- > [View an example WorldView-3 report here](https://www.bendirt.com/assets/documents/asp_plot_example_report.pdf).
23
+ > [View an example WorldView report here](notebooks/WorldView/asp_plot_report_atlanta_tile.pdf).
24
24
  >
25
25
 
26
26
  ## Motivation
@@ -5,7 +5,7 @@
5
5
  Scripts and notebooks to visualize output from the [NASA Ames Stereo Pipeline (ASP)](https://github.com/NeoGeographyToolkit/StereoPipeline).
6
6
 
7
7
  > [!IMPORTANT]
8
- > [View an example WorldView-3 report here](https://www.bendirt.com/assets/documents/asp_plot_example_report.pdf).
8
+ > [View an example WorldView report here](notebooks/WorldView/asp_plot_report_atlanta_tile.pdf).
9
9
  >
10
10
 
11
11
  ## Motivation
@@ -9,10 +9,11 @@ import contextily as ctx
9
9
  from asp_plot.altimetry import Altimetry
10
10
  from asp_plot.bundle_adjust import PlotBundleAdjustFiles, ReadBundleAdjustFiles
11
11
  from asp_plot.processing_parameters import ProcessingParameters
12
+ from asp_plot.report import ReportMetadata, ReportSection, compile_report
12
13
  from asp_plot.scenes import ScenePlotter
13
14
  from asp_plot.stereo import StereoPlotter
14
15
  from asp_plot.stereo_geometry import StereoGeometryPlotter
15
- from asp_plot.utils import Raster, compile_report
16
+ from asp_plot.utils import Raster
16
17
 
17
18
 
18
19
  @click.command()
@@ -140,17 +141,21 @@ def main(
140
141
  directory, os.path.join(stereo_directory, report_filename)
141
142
  )
142
143
 
144
+ sections = []
143
145
  figure_counter = count(0)
144
146
 
145
- asp_dem = StereoPlotter(
147
+ # Initialize StereoPlotter early (needed for DEM info and multiple plot types)
148
+ stereo_plotter = StereoPlotter(
146
149
  directory,
147
150
  stereo_directory,
148
151
  reference_dem=reference_dem,
149
152
  dem_fn=dem_filename,
150
153
  dem_gsd=dem_gsd,
151
- ).dem_fn
154
+ )
155
+ asp_dem = stereo_plotter.dem_fn
152
156
 
153
- # Set map CRS from output DEM
157
+ # Set map CRS from output DEM and collect DEM metadata
158
+ report_metadata = None
154
159
  if map_crs is None:
155
160
  if asp_dem and os.path.exists(asp_dem):
156
161
  try:
@@ -164,6 +169,30 @@ def main(
164
169
  )
165
170
  map_crs = "EPSG:4326"
166
171
 
172
+ # Collect DEM metadata for the report title page
173
+ if asp_dem and os.path.exists(asp_dem):
174
+ try:
175
+ dem_raster = Raster(asp_dem)
176
+ dem_data = dem_raster.read_array()
177
+ total_pixels = dem_data.size
178
+ nodata_pixels = dem_data.mask.sum() if hasattr(dem_data.mask, "sum") else 0
179
+ nodata_pct = (nodata_pixels / total_pixels * 100) if total_pixels else 0.0
180
+ valid = dem_data.compressed()
181
+ elev_range = (
182
+ (float(valid.min()), float(valid.max())) if valid.size else (0, 0)
183
+ )
184
+ report_metadata = ReportMetadata(
185
+ dem_dimensions=(dem_raster.ds.width, dem_raster.ds.height),
186
+ dem_gsd_m=dem_raster.get_gsd(),
187
+ dem_crs=map_crs or "",
188
+ dem_nodata_percent=nodata_pct,
189
+ dem_elevation_range=elev_range,
190
+ dem_filename=os.path.basename(asp_dem),
191
+ reference_dem=reference_dem or "",
192
+ )
193
+ except Exception as e:
194
+ print(f"\nCould not collect DEM metadata: {e}\n")
195
+
167
196
  # TODO: Centralize this in plotting utils, should not need ctx import in the CLI wrapper
168
197
  if add_basemap:
169
198
  ctx_kwargs = {
@@ -175,56 +204,84 @@ def main(
175
204
  else:
176
205
  ctx_kwargs = {}
177
206
 
178
- # Stereo plots
179
- plotter = StereoPlotter(
180
- directory,
181
- stereo_directory,
182
- reference_dem=reference_dem,
183
- dem_fn=dem_filename,
184
- dem_gsd=dem_gsd,
185
- title="Hillshade with details",
207
+ # ---- Section 1: Input Scenes ----
208
+ fig_fn = f"{next(figure_counter):02}.png"
209
+ scene_plotter = ScenePlotter(directory, stereo_directory, title="Input Scenes")
210
+ scene_plotter.plot_scenes(save_dir=plots_directory, fig_fn=fig_fn)
211
+ sections.append(
212
+ ReportSection(
213
+ title="Input Scenes",
214
+ image_path=os.path.join(plots_directory, fig_fn),
215
+ caption="Left and right input scenes used for stereo processing.",
216
+ )
186
217
  )
187
218
 
188
- plotter.plot_detailed_hillshade(
189
- subset_km=subset_km,
190
- save_dir=plots_directory,
191
- fig_fn=f"{next(figure_counter):02}.png",
192
- )
219
+ # ---- Section 2: Stereo Geometry (conditional) ----
220
+ if plot_geometry:
221
+ fig_fn = f"{next(figure_counter):02}.png"
222
+ geom_plotter = StereoGeometryPlotter(directory, add_basemap=add_basemap)
223
+ geom_plotter.dg_geom_plot(save_dir=plots_directory, fig_fn=fig_fn)
224
+ sections.append(
225
+ ReportSection(
226
+ title="Stereo Geometry",
227
+ image_path=os.path.join(plots_directory, fig_fn),
228
+ caption="Stereo acquisition geometry skyplot and map view showing satellite viewing angles and scene footprints.",
229
+ )
230
+ )
193
231
 
194
- plotter.title = "Stereo DEM Results"
195
- plotter.plot_dem_results(
196
- save_dir=plots_directory,
197
- fig_fn=f"{next(figure_counter):02}.png",
232
+ # ---- Section 3: Match Points ----
233
+ fig_fn = f"{next(figure_counter):02}.png"
234
+ stereo_plotter.title = "Stereo Match Points"
235
+ stereo_plotter.plot_match_points(save_dir=plots_directory, fig_fn=fig_fn)
236
+ sections.append(
237
+ ReportSection(
238
+ title="Match Points",
239
+ image_path=os.path.join(plots_directory, fig_fn),
240
+ caption="Interest point matches between left and right images identified during stereo correlation.",
241
+ )
198
242
  )
199
243
 
200
- plotter.title = "Disparity (pixels)"
201
- plotter.plot_disparity(
202
- unit="pixels",
203
- quiver=True,
204
- save_dir=plots_directory,
205
- fig_fn=f"{next(figure_counter):02}.png",
244
+ # ---- Section 4: Detailed Hillshade ----
245
+ fig_fn = f"{next(figure_counter):02}.png"
246
+ stereo_plotter.title = "Hillshade with details"
247
+ stereo_plotter.plot_detailed_hillshade(
248
+ subset_km=subset_km, save_dir=plots_directory, fig_fn=fig_fn
206
249
  )
207
-
208
- plotter.title = "Stereo Match Points"
209
- plotter.plot_match_points(
210
- save_dir=plots_directory,
211
- fig_fn=f"{next(figure_counter):02}.png",
250
+ sections.append(
251
+ ReportSection(
252
+ title="Detailed Hillshade",
253
+ image_path=os.path.join(plots_directory, fig_fn),
254
+ caption=f"DEM hillshade with {subset_km} km detail subset in second row. If available, corresponding mapprojected ortho image subsets are displayed in the bottom row.",
255
+ )
212
256
  )
213
257
 
214
- # Scene plot
215
- plotter = ScenePlotter(directory, stereo_directory, title="Stereo Scenes")
216
- plotter.plot_scenes(
217
- save_dir=plots_directory, fig_fn=f"{next(figure_counter):02}.png"
258
+ # ---- Section 5: DEM Results ----
259
+ fig_fn = f"{next(figure_counter):02}.png"
260
+ stereo_plotter.title = "Stereo DEM Results"
261
+ stereo_plotter.plot_dem_results(save_dir=plots_directory, fig_fn=fig_fn)
262
+ sections.append(
263
+ ReportSection(
264
+ title="DEM Results",
265
+ image_path=os.path.join(plots_directory, fig_fn),
266
+ caption="Output DEM with intersection error map and difference relative to the reference DEM used in processing.",
267
+ )
218
268
  )
219
269
 
220
- # Geometry plot
221
- if plot_geometry:
222
- plotter = StereoGeometryPlotter(directory, add_basemap=add_basemap)
223
- plotter.dg_geom_plot(
224
- save_dir=plots_directory, fig_fn=f"{next(figure_counter):02}.png"
270
+ # ---- Section 6: Disparity ----
271
+ fig_fn = f"{next(figure_counter):02}.png"
272
+ stereo_plotter.title = "Disparity (pixels)"
273
+ stereo_plotter.plot_disparity(
274
+ unit="pixels", quiver=True, save_dir=plots_directory, fig_fn=fig_fn
275
+ )
276
+ sections.append(
277
+ ReportSection(
278
+ title="Disparity",
279
+ image_path=os.path.join(plots_directory, fig_fn),
280
+ caption="Horizontal and vertical disparity maps in pixels with quiver overlay.",
225
281
  )
282
+ )
226
283
 
227
- # ICESat-2 comparison
284
+ # ---- Sections 7-10: ICESat-2 (conditional) ----
228
285
  if plot_icesat:
229
286
  icesat = Altimetry(directory=directory, dem_fn=asp_dem)
230
287
 
@@ -242,37 +299,69 @@ def main(
242
299
 
243
300
  icesat.predefined_temporal_filter_atl06sr(date=icesat_filter_date)
244
301
 
302
+ fig_fn = f"{next(figure_counter):02}.png"
245
303
  icesat.mapview_plot_atl06sr_to_dem(
246
304
  key="all",
247
305
  save_dir=plots_directory,
248
- fig_fn=f"{next(figure_counter):02}.png",
306
+ fig_fn=fig_fn,
249
307
  map_crs=map_crs,
250
308
  **ctx_kwargs,
251
309
  )
310
+ sections.append(
311
+ ReportSection(
312
+ title="ICESat-2 ATL06-SR Map (All)",
313
+ image_path=os.path.join(plots_directory, fig_fn),
314
+ caption="ICESat-2 ATL06-SR elevation differences (all processing levels) vs. ASP DEM.",
315
+ )
316
+ )
252
317
 
318
+ fig_fn = f"{next(figure_counter):02}.png"
253
319
  icesat.histogram(
254
320
  key="all",
255
321
  plot_aligned=False,
256
322
  save_dir=plots_directory,
257
- fig_fn=f"{next(figure_counter):02}.png",
323
+ fig_fn=fig_fn,
324
+ )
325
+ sections.append(
326
+ ReportSection(
327
+ title="ICESat-2 ATL06-SR Histogram (All)",
328
+ image_path=os.path.join(plots_directory, fig_fn),
329
+ caption="Distribution of elevation differences between ICESat-2 ATL06-SR (all) and ASP DEM.",
330
+ )
258
331
  )
259
332
 
333
+ fig_fn = f"{next(figure_counter):02}.png"
260
334
  icesat.mapview_plot_atl06sr_to_dem(
261
335
  key="ground_seasonal",
262
336
  save_dir=plots_directory,
263
- fig_fn=f"{next(figure_counter):02}.png",
337
+ fig_fn=fig_fn,
264
338
  map_crs=map_crs,
265
339
  **ctx_kwargs,
266
340
  )
341
+ sections.append(
342
+ ReportSection(
343
+ title="ICESat-2 ATL06-SR Map (Ground, Seasonal)",
344
+ image_path=os.path.join(plots_directory, fig_fn),
345
+ caption="ICESat-2 ATL06-SR elevation differences (ground, seasonally filtered) vs. ASP DEM.",
346
+ )
347
+ )
267
348
 
349
+ fig_fn = f"{next(figure_counter):02}.png"
268
350
  icesat.histogram(
269
351
  key="ground_seasonal",
270
352
  plot_aligned=False,
271
353
  save_dir=plots_directory,
272
- fig_fn=f"{next(figure_counter):02}.png",
354
+ fig_fn=fig_fn,
355
+ )
356
+ sections.append(
357
+ ReportSection(
358
+ title="ICESat-2 ATL06-SR Histogram (Ground, Seasonal)",
359
+ image_path=os.path.join(plots_directory, fig_fn),
360
+ caption="Distribution of elevation differences between ICESat-2 ATL06-SR (ground, seasonal) and ASP DEM.",
361
+ )
273
362
  )
274
363
 
275
- # Bundle adjustment plots
364
+ # ---- Sections 11+: Bundle Adjustment (conditional) ----
276
365
  if bundle_adjust_directory:
277
366
  try:
278
367
  ba_files = ReadBundleAdjustFiles(directory, bundle_adjust_directory)
@@ -286,27 +375,43 @@ def main(
286
375
  title="Bundle Adjust Initial and Final Residuals (Log Scale)",
287
376
  )
288
377
 
378
+ fig_fn = f"{next(figure_counter):02}.png"
289
379
  plotter.plot_n_gdfs(
290
380
  column_name="mean_residual",
291
381
  cbar_label="Mean residual (px)",
292
382
  map_crs=map_crs,
293
383
  save_dir=plots_directory,
294
- fig_fn=f"{next(figure_counter):02}.png",
384
+ fig_fn=fig_fn,
295
385
  **ctx_kwargs,
296
386
  )
387
+ sections.append(
388
+ ReportSection(
389
+ title="Bundle Adjust Residuals (Log Scale)",
390
+ image_path=os.path.join(plots_directory, fig_fn),
391
+ caption="Initial and final bundle adjustment residuals on a logarithmic scale.",
392
+ )
393
+ )
297
394
 
298
395
  plotter.lognorm = False
299
396
  plotter.title = "Bundle Adjust Initial and Final Residuals (Linear Scale)"
300
397
 
398
+ fig_fn = f"{next(figure_counter):02}.png"
301
399
  plotter.plot_n_gdfs(
302
400
  column_name="mean_residual",
303
401
  cbar_label="Mean residual (px)",
304
402
  common_clim=False,
305
403
  map_crs=map_crs,
306
404
  save_dir=plots_directory,
307
- fig_fn=f"{next(figure_counter):02}.png",
405
+ fig_fn=fig_fn,
308
406
  **ctx_kwargs,
309
407
  )
408
+ sections.append(
409
+ ReportSection(
410
+ title="Bundle Adjust Residuals (Linear Scale)",
411
+ image_path=os.path.join(plots_directory, fig_fn),
412
+ caption="Initial and final bundle adjustment residuals on a linear scale.",
413
+ )
414
+ )
310
415
 
311
416
  # Map-projected residuals (requires reference DEM in bundle_adjust)
312
417
  try:
@@ -317,14 +422,22 @@ def main(
317
422
  title="Bundle Adjust Midpoint distance between\nfinal interest points projected onto reference DEM",
318
423
  )
319
424
 
425
+ fig_fn = f"{next(figure_counter):02}.png"
320
426
  plotter.plot_n_gdfs(
321
427
  column_name="mapproj_ip_dist_meters",
322
428
  cbar_label="Interest point distance (m)",
323
429
  map_crs=map_crs,
324
430
  save_dir=plots_directory,
325
- fig_fn=f"{next(figure_counter):02}.png",
431
+ fig_fn=fig_fn,
326
432
  **ctx_kwargs,
327
433
  )
434
+ sections.append(
435
+ ReportSection(
436
+ title="Map-Projected Residuals",
437
+ image_path=os.path.join(plots_directory, fig_fn),
438
+ caption="Midpoint distance between final interest points projected onto the reference DEM used in processing.",
439
+ )
440
+ )
328
441
  except ValueError as e:
329
442
  print(f"\n\nSkipping map-projected residuals plot: {e}\n\n")
330
443
 
@@ -340,6 +453,7 @@ def main(
340
453
  title="Bundle Adjust Initial and Final Geodiff vs. Reference DEM",
341
454
  )
342
455
 
456
+ fig_fn = f"{next(figure_counter):02}.png"
343
457
  plotter.plot_n_gdfs(
344
458
  column_name="height_diff_meters",
345
459
  cbar_label="Height difference (m)",
@@ -347,9 +461,16 @@ def main(
347
461
  cmap="RdBu",
348
462
  symm_clim=True,
349
463
  save_dir=plots_directory,
350
- fig_fn=f"{next(figure_counter):02}.png",
464
+ fig_fn=fig_fn,
351
465
  **ctx_kwargs,
352
466
  )
467
+ sections.append(
468
+ ReportSection(
469
+ title="Geodiff vs. Reference DEM",
470
+ image_path=os.path.join(plots_directory, fig_fn),
471
+ caption="Initial and final geodiff height differences compared to the reference DEM used in processing.",
472
+ )
473
+ )
353
474
  except ValueError as e:
354
475
  print(
355
476
  f"\n\nSkipping geodiff plots (requires --mapproj-dem flag in bundle_adjust): {e}\n\n"
@@ -369,10 +490,11 @@ def main(
369
490
  processing_parameters_dict = processing_parameters.from_log_files()
370
491
 
371
492
  compile_report(
372
- plots_directory,
493
+ sections,
373
494
  processing_parameters_dict,
374
495
  report_pdf_path,
375
496
  report_title=report_title,
497
+ report_metadata=report_metadata,
376
498
  )
377
499
 
378
500
  shutil.rmtree(plots_directory)
@@ -0,0 +1,296 @@
1
+ import logging
2
+ import os
3
+ import textwrap
4
+ from dataclasses import dataclass
5
+
6
+ from fpdf import FPDF
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ @dataclass
12
+ class ReportSection:
13
+ """A section of the PDF report containing a figure with title and caption.
14
+
15
+ Attributes
16
+ ----------
17
+ title : str
18
+ Section heading displayed above the figure.
19
+ image_path : str
20
+ Absolute path to the PNG image file.
21
+ caption : str
22
+ Caption text displayed below the figure.
23
+ figure_number : int
24
+ Auto-assigned by compile_report().
25
+ """
26
+
27
+ title: str
28
+ image_path: str
29
+ caption: str = ""
30
+ figure_number: int = 0
31
+
32
+
33
+ @dataclass
34
+ class ReportMetadata:
35
+ """Metadata about the output DEM for the report title page.
36
+
37
+ Attributes
38
+ ----------
39
+ dem_dimensions : tuple
40
+ (width, height) in pixels.
41
+ dem_gsd_m : float
42
+ Ground sample distance in meters.
43
+ dem_crs : str
44
+ Coordinate reference system string (e.g. "EPSG:32616").
45
+ dem_nodata_percent : float
46
+ Percentage of nodata pixels.
47
+ dem_elevation_range : tuple
48
+ (min, max) elevation in meters.
49
+ dem_filename : str
50
+ DEM filename.
51
+ reference_dem : str
52
+ Reference DEM path or description.
53
+ """
54
+
55
+ dem_dimensions: tuple = (0, 0)
56
+ dem_gsd_m: float = 0.0
57
+ dem_crs: str = ""
58
+ dem_nodata_percent: float = 0.0
59
+ dem_elevation_range: tuple = (0, 0)
60
+ dem_filename: str = ""
61
+ reference_dem: str = ""
62
+
63
+
64
+ class ASPReportPDF(FPDF):
65
+ """FPDF subclass with custom header and footer for ASP reports."""
66
+
67
+ def __init__(self, report_title="ASP Output Quality Report"):
68
+ super().__init__(orientation="P", unit="mm", format="Letter")
69
+ self.report_title = report_title
70
+ self.set_auto_page_break(auto=True, margin=20)
71
+ self.set_margins(15, 15, 15)
72
+
73
+ def header(self):
74
+ if self.page_no() > 1:
75
+ self.set_font("Helvetica", "I", 8)
76
+ self.set_text_color(128, 128, 128)
77
+ self.cell(0, 6, self.report_title, align="L")
78
+ self.ln(2)
79
+ self.set_draw_color(200, 200, 200)
80
+ self.line(15, self.get_y(), self.w - 15, self.get_y())
81
+ self.ln(4)
82
+ self.set_text_color(0, 0, 0)
83
+
84
+ def footer(self):
85
+ self.set_y(-15)
86
+ self.set_font("Helvetica", "I", 8)
87
+ self.set_text_color(128, 128, 128)
88
+ self.cell(0, 10, f"Page {self.page_no()} of {{nb}}", align="C")
89
+ self.set_text_color(0, 0, 0)
90
+
91
+
92
+ def compile_report(
93
+ sections,
94
+ processing_parameters_dict,
95
+ report_pdf_path,
96
+ report_title="ASP Output Quality Report",
97
+ report_metadata=None,
98
+ ):
99
+ """
100
+ Compile a PDF report with ASP processing results and plots.
101
+
102
+ Creates a structured PDF report with a title page, figure sections
103
+ with captions, and a processing parameters appendix.
104
+
105
+ Parameters
106
+ ----------
107
+ sections : list of ReportSection
108
+ Ordered list of report sections, each containing a title,
109
+ image path, and optional caption.
110
+ processing_parameters_dict : dict
111
+ Dictionary containing processing parameters from ASP logs.
112
+ report_pdf_path : str
113
+ Output path for the PDF report.
114
+ report_title : str, optional
115
+ Title for the report. Default is "ASP Output Quality Report".
116
+ report_metadata : ReportMetadata, optional
117
+ DEM metadata for the title page summary table. Default is None.
118
+
119
+ Returns
120
+ -------
121
+ None
122
+ Generates a PDF report at the specified path.
123
+
124
+ Notes
125
+ -----
126
+ Required keys in processing_parameters_dict:
127
+ - processing_timestamp: When the processing was performed
128
+ - reference_dem: Path to reference DEM used
129
+ - bundle_adjust: Bundle adjustment command
130
+ - bundle_adjust_run_time: Time to run bundle adjustment
131
+ - stereo: Stereo command
132
+ - stereo_run_time: Time to run stereo
133
+ - point2dem: Point2dem command
134
+ - point2dem_run_time: Time to run point2dem
135
+ """
136
+ pdf = ASPReportPDF(report_title=report_title)
137
+ pdf.alias_nb_pages()
138
+
139
+ # ---- Title page ----
140
+ pdf.add_page()
141
+ pdf.ln(30)
142
+ pdf.set_font("Helvetica", "B", 22)
143
+ pdf.multi_cell(0, 12, report_title, align="C")
144
+ pdf.ln(8)
145
+ pdf.set_font("Helvetica", "", 11)
146
+ processing_date = processing_parameters_dict.get("processing_timestamp", "")
147
+ pdf.cell(
148
+ 0,
149
+ 8,
150
+ f"Processed on: {processing_date}",
151
+ align="C",
152
+ new_x="LMARGIN",
153
+ new_y="NEXT",
154
+ )
155
+ pdf.ln(10)
156
+
157
+ if report_metadata is not None:
158
+ _add_metadata_table(pdf, report_metadata)
159
+
160
+ # ---- Figure sections ----
161
+ for i, section in enumerate(sections, start=1):
162
+ section.figure_number = i
163
+ if not os.path.exists(section.image_path):
164
+ logger.warning(f"Image not found, skipping: {section.image_path}")
165
+ continue
166
+
167
+ pdf.add_page()
168
+ pdf.set_font("Helvetica", "B", 14)
169
+ pdf.cell(0, 10, section.title, new_x="LMARGIN", new_y="NEXT")
170
+ pdf.ln(2)
171
+
172
+ usable_width = pdf.w - pdf.l_margin - pdf.r_margin
173
+ pdf.image(section.image_path, x=pdf.l_margin, w=usable_width)
174
+
175
+ if section.caption:
176
+ pdf.ln(3)
177
+ pdf.set_font("Helvetica", "I", 9)
178
+ pdf.multi_cell(0, 5, f"Figure {section.figure_number}: {section.caption}")
179
+
180
+ # ---- Processing Parameters page ----
181
+ pdf.add_page()
182
+ pdf.set_font("Helvetica", "B", 16)
183
+ pdf.cell(0, 10, "Processing Parameters", new_x="LMARGIN", new_y="NEXT")
184
+ pdf.ln(4)
185
+
186
+ _add_runtime_table(pdf, processing_parameters_dict)
187
+ pdf.ln(6)
188
+
189
+ ref_dem = processing_parameters_dict.get("reference_dem", "")
190
+ if ref_dem:
191
+ pdf.set_font("Helvetica", "B", 10)
192
+ pdf.cell(0, 7, "Reference DEM:", new_x="LMARGIN", new_y="NEXT")
193
+ pdf.set_font("Courier", "", 7)
194
+ pdf.multi_cell(0, 4, ref_dem)
195
+ pdf.ln(4)
196
+
197
+ for key, label in [
198
+ ("bundle_adjust", "Bundle Adjust"),
199
+ ("stereo", "Stereo"),
200
+ ("point2dem", "point2dem"),
201
+ ]:
202
+ cmd = processing_parameters_dict.get(key, "")
203
+ if cmd:
204
+ pdf.set_font("Helvetica", "B", 10)
205
+ pdf.cell(0, 7, f"{label} Command:", new_x="LMARGIN", new_y="NEXT")
206
+ pdf.set_font("Courier", "", 7)
207
+ wrapped = textwrap.fill(cmd, width=120)
208
+ pdf.multi_cell(0, 4, wrapped)
209
+ pdf.ln(4)
210
+
211
+ pdf.output(report_pdf_path)
212
+
213
+
214
+ def _add_metadata_table(pdf, metadata):
215
+ """Add DEM metadata summary table to the PDF.
216
+
217
+ Parameters
218
+ ----------
219
+ pdf : ASPReportPDF
220
+ The PDF document.
221
+ metadata : ReportMetadata
222
+ DEM metadata to display.
223
+ """
224
+ pdf.set_font("Helvetica", "B", 12)
225
+ pdf.cell(0, 10, "DEM Summary", align="C", new_x="LMARGIN", new_y="NEXT")
226
+ pdf.ln(2)
227
+
228
+ rows = []
229
+ if metadata.dem_filename:
230
+ rows.append(("DEM File", metadata.dem_filename))
231
+ if metadata.dem_dimensions != (0, 0):
232
+ w, h = metadata.dem_dimensions
233
+ rows.append(("Dimensions (px)", f"{w} x {h}"))
234
+ if metadata.dem_gsd_m:
235
+ rows.append(("GSD (m)", f"{metadata.dem_gsd_m:.2f}"))
236
+ if metadata.dem_crs:
237
+ rows.append(("CRS", metadata.dem_crs))
238
+ if metadata.dem_nodata_percent:
239
+ rows.append(("Nodata (%)", f"{metadata.dem_nodata_percent:.1f}"))
240
+ if metadata.dem_elevation_range != (0, 0):
241
+ lo, hi = metadata.dem_elevation_range
242
+ rows.append(("Elevation Range (m)", f"{lo:.1f} to {hi:.1f}"))
243
+ if metadata.reference_dem:
244
+ rows.append(("Reference DEM", metadata.reference_dem))
245
+
246
+ if not rows:
247
+ return
248
+
249
+ col_w = (pdf.w - pdf.l_margin - pdf.r_margin) / 2
250
+ table_x = pdf.l_margin
251
+
252
+ pdf.set_font("Helvetica", "B", 9)
253
+ pdf.set_fill_color(220, 220, 220)
254
+ pdf.set_x(table_x)
255
+ pdf.cell(col_w, 7, "Property", border=1, fill=True)
256
+ pdf.cell(col_w, 7, "Value", border=1, fill=True, new_x="LMARGIN", new_y="NEXT")
257
+
258
+ pdf.set_font("Helvetica", "", 9)
259
+ for prop, val in rows:
260
+ pdf.set_x(table_x)
261
+ pdf.cell(col_w, 7, prop, border=1)
262
+ pdf.cell(col_w, 7, str(val), border=1, new_x="LMARGIN", new_y="NEXT")
263
+
264
+ pdf.ln(4)
265
+
266
+
267
+ def _add_runtime_table(pdf, params):
268
+ """Add runtime summary table to the PDF.
269
+
270
+ Parameters
271
+ ----------
272
+ pdf : ASPReportPDF
273
+ The PDF document.
274
+ params : dict
275
+ Processing parameters dictionary.
276
+ """
277
+ pdf.set_font("Helvetica", "B", 11)
278
+ pdf.cell(0, 8, "Runtime Summary", new_x="LMARGIN", new_y="NEXT")
279
+ pdf.ln(2)
280
+
281
+ col_w = (pdf.w - pdf.l_margin - pdf.r_margin) / 2
282
+
283
+ pdf.set_font("Helvetica", "B", 9)
284
+ pdf.set_fill_color(220, 220, 220)
285
+ pdf.cell(col_w, 7, "Step", border=1, fill=True)
286
+ pdf.cell(col_w, 7, "Runtime", border=1, fill=True, new_x="LMARGIN", new_y="NEXT")
287
+
288
+ pdf.set_font("Helvetica", "", 9)
289
+ for key, label in [
290
+ ("bundle_adjust_run_time", "Bundle Adjust"),
291
+ ("stereo_run_time", "Stereo"),
292
+ ("point2dem_run_time", "point2dem"),
293
+ ]:
294
+ runtime = params.get(key, "N/A")
295
+ pdf.cell(col_w, 7, label, border=1)
296
+ pdf.cell(col_w, 7, str(runtime), border=1, new_x="LMARGIN", new_y="NEXT")
@@ -11,7 +11,6 @@ import matplotlib.pyplot as plt
11
11
  import numpy as np
12
12
  import rasterio as rio
13
13
  import rioxarray
14
- from markdown_pdf import MarkdownPdf, Section
15
14
  from mpl_toolkits.axes_grid1 import make_axes_locatable
16
15
  from osgeo import gdal
17
16
  from rasterio.errors import NotGeoreferencedWarning
@@ -134,99 +133,6 @@ def save_figure(fig, save_dir=None, fig_fn=None, dpi=150):
134
133
  raise ValueError("\n\nPlease provide a save directory and figure filename.\n\n")
135
134
 
136
135
 
137
- def compile_report(
138
- plots_directory, processing_parameters_dict, report_pdf_path, report_title=None
139
- ):
140
- """
141
- Compile a PDF report with ASP processing results and plots.
142
-
143
- Creates a structured PDF report containing processing parameters and
144
- generated plots from ASP processing. The plots are converted from PNG
145
- to JPEG for better compression in the PDF.
146
-
147
- Parameters
148
- ----------
149
- plots_directory : str
150
- Directory containing plot files (PNG format)
151
- processing_parameters_dict : dict
152
- Dictionary containing processing parameters from ASP logs
153
- report_pdf_path : str
154
- Output path for the PDF report
155
- report_title : str, optional
156
- Title for the report. If None, uses the parent directory name
157
-
158
- Returns
159
- -------
160
- None
161
- Generates a PDF report at the specified path
162
-
163
- Notes
164
- -----
165
- Required keys in processing_parameters_dict:
166
- - processing_timestamp: When the processing was performed
167
- - reference_dem: Path to reference DEM used
168
- - bundle_adjust: Bundle adjustment command
169
- - bundle_adjust_run_time: Time to run bundle adjustment
170
- - stereo: Stereo command
171
- - stereo_run_time: Time to run stereo
172
- - point2dem: Point2dem command
173
- - point2dem_run_time: Time to run point2dem
174
-
175
- The function converts PNG files to temporary JPG files for the report,
176
- then deletes the temporary files afterward.
177
- """
178
- from PIL import Image
179
-
180
- files = [f for f in os.listdir(plots_directory) if f.endswith(".png")]
181
- files.sort()
182
-
183
- # Convert .png files to .jpg with 95% quality
184
- compressed_files = []
185
- for file in files:
186
- png_path = os.path.join(plots_directory, file)
187
- jpg_file = file.replace(".png", ".jpg")
188
- jpg_path = os.path.join(plots_directory, jpg_file)
189
-
190
- with Image.open(png_path) as img:
191
- img = img.convert("RGB")
192
- img.save(jpg_path, "JPEG", quality=95)
193
-
194
- compressed_files.append(jpg_file)
195
-
196
- processing_date = processing_parameters_dict["processing_timestamp"]
197
-
198
- if report_title is None:
199
- report_title = os.path.basename(os.path.dirname(report_pdf_path))
200
-
201
- report_title = (
202
- f"# ASP Report\n\n## {report_title:}\n\nProcessed on: {processing_date:}"
203
- )
204
- reference_dem_string = (
205
- f"### Reference DEM:\n\n`{processing_parameters_dict['reference_dem']:}`"
206
- )
207
- ba_string = f"### Bundle Adjust ({processing_parameters_dict['bundle_adjust_run_time']:}):\n\n`{processing_parameters_dict['bundle_adjust']:}`"
208
- stereo_string = f"### Stereo ({processing_parameters_dict['stereo_run_time']:}):\n\n`{processing_parameters_dict['stereo']:}`"
209
- point2dem_string = f"### point2dem ({processing_parameters_dict['point2dem_run_time']}):\n\n`{processing_parameters_dict['point2dem']:}`"
210
-
211
- pdf = MarkdownPdf()
212
-
213
- pdf.add_section(Section(f"{report_title:}\n\n"))
214
- pdf.add_section(
215
- Section(
216
- f"## Processing Parameters\n\n{reference_dem_string:}\n\n{ba_string:}\n\n{stereo_string}\n\n{point2dem_string}\n\n"
217
- )
218
- )
219
- plots = "".join([f"![]({file})\n\n" for file in compressed_files])
220
- pdf.add_section(Section(f"## Plots\n\n{plots:}", root=plots_directory))
221
-
222
- pdf.save(report_pdf_path)
223
-
224
- # cleanup temporary JPEG files
225
- for file in compressed_files:
226
- jpg_path = os.path.join(plots_directory, file)
227
- os.remove(jpg_path)
228
-
229
-
230
136
  def get_xml_tag(xml, tag, all=False):
231
137
  """
232
138
  Extract value(s) from XML tag(s).
@@ -19,5 +19,4 @@ dependencies:
19
19
  - matplotlib-scalebar
20
20
  - click
21
21
  - sliderule
22
- - pip:
23
- - markdown-pdf
22
+ - fpdf2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "asp_plot"
7
- version = "1.5.0"
7
+ version = "1.6.0"
8
8
  license = {text = "BSD-3-Clause"}
9
9
  authors = [
10
10
  { name="Ben Purinton", email="purinton@uw.edu" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes