import os import json import math import shutil from opendm import log from opendm import io from opendm import system from opendm import types from opendm.shots import get_geojson_shots_from_opensfm from opendm.osfm import OSFMContext from opendm import gsd from opendm.point_cloud import export_info_json from opendm.cropper import Cropper from opendm.orthophoto import get_orthophoto_vars, get_max_memory, generate_png from opendm.tiles.tiler import generate_colored_hillshade from opendm.utils import get_raster_stats, np_from_json def hms(seconds): h = seconds // 3600 m = seconds % 3600 // 60 s = seconds % 3600 % 60 if h > 0: return '{}h:{}m:{}s'.format(h, m, round(s, 0)) elif m > 0: return '{}m:{}s'.format(m, round(s, 0)) else: return '{}s'.format(round(s, 0)) def generate_point_cloud_stats(input_point_cloud, pc_info_file, rerun=False): if not os.path.exists(pc_info_file) or rerun: export_info_json(input_point_cloud, pc_info_file) if os.path.exists(pc_info_file): with open(pc_info_file, 'r') as f: return json.loads(f.read()) class ODMReport(types.ODM_Stage): def process(self, args, outputs): tree = outputs['tree'] reconstruction = outputs['reconstruction'] if not os.path.exists(tree.odm_report): system.mkdir_p(tree.odm_report) log.ODM_INFO("Exporting shots.geojson") shots_geojson = os.path.join(tree.odm_report, "shots.geojson") if not io.file_exists(shots_geojson) or self.rerun(): # Extract geographical camera shots if reconstruction.is_georeferenced(): # Check if alignment has been performed (we need to transform our shots if so) a_matrix = None if io.file_exists(tree.odm_georeferencing_alignment_matrix): with open(tree.odm_georeferencing_alignment_matrix, 'r') as f: a_matrix = np_from_json(f.read()) log.ODM_INFO("Aligning shots to %s" % a_matrix) shots = get_geojson_shots_from_opensfm(tree.opensfm_reconstruction, utm_srs=reconstruction.get_proj_srs(), utm_offset=reconstruction.georef.utm_offset(), a_matrix=a_matrix) else: # Pseudo geo shots = get_geojson_shots_from_opensfm(tree.opensfm_reconstruction, pseudo_geotiff=tree.odm_orthophoto_tif) if shots: with open(shots_geojson, "w") as fout: fout.write(json.dumps(shots)) log.ODM_INFO("Wrote %s" % shots_geojson) else: log.ODM_WARNING("Cannot extract shots") else: log.ODM_WARNING('Found a valid shots file in: %s' % shots_geojson) if args.skip_report: # Stop right here log.ODM_WARNING("Skipping report generation as requested") return # Augment OpenSfM stats file with our own stats odm_stats_json = os.path.join(tree.odm_report, "stats.json") octx = OSFMContext(tree.opensfm) osfm_stats_json = octx.path("stats", "stats.json") codem_stats_json = octx.path("stats", "codem", "registration.json") odm_stats = None point_cloud_file = None views_dimension = None if not os.path.exists(odm_stats_json) or self.rerun(): if os.path.exists(osfm_stats_json): with open(osfm_stats_json, 'r') as f: odm_stats = json.loads(f.read()) # Add point cloud stats if os.path.exists(tree.odm_georeferencing_model_laz): point_cloud_file = tree.odm_georeferencing_model_laz views_dimension = "UserData" # pc_info_file should have been generated by cropper pc_info_file = os.path.join(tree.odm_georeferencing, "odm_georeferenced_model.info.json") odm_stats['point_cloud_statistics'] = generate_point_cloud_stats(tree.odm_georeferencing_model_laz, pc_info_file, self.rerun()) else: ply_pc = os.path.join(tree.odm_filterpoints, "point_cloud.ply") if os.path.exists(ply_pc): point_cloud_file = ply_pc views_dimension = "views" pc_info_file = os.path.join(tree.odm_filterpoints, "point_cloud.info.json") odm_stats['point_cloud_statistics'] = generate_point_cloud_stats(ply_pc, pc_info_file, self.rerun()) else: log.ODM_WARNING("No point cloud found") odm_stats['point_cloud_statistics']['dense'] = not args.fast_orthophoto # Add runtime stats total_time = (system.now_raw() - outputs['start_time']).total_seconds() odm_stats['odm_processing_statistics'] = { 'total_time': total_time, 'total_time_human': hms(total_time), 'average_gsd': gsd.opensfm_reconstruction_average_gsd(octx.recon_file(), use_all_shots=reconstruction.has_gcp()), } # Add CODEM stats if os.path.exists(codem_stats_json): with open(codem_stats_json, 'r') as f: odm_stats['align'] = json.loads(f.read()) with open(odm_stats_json, 'w') as f: f.write(json.dumps(odm_stats)) else: log.ODM_WARNING("Cannot generate report, OpenSfM stats are missing") else: log.ODM_WARNING("Reading existing stats %s" % odm_stats_json) with open(odm_stats_json, 'r') as f: odm_stats = json.loads(f.read()) # Generate overlap diagram if odm_stats.get('point_cloud_statistics') and point_cloud_file and views_dimension: bounds = odm_stats['point_cloud_statistics'].get('stats', {}).get('bbox', {}).get('native', {}).get('bbox') if bounds: image_target_size = 1400 # pixels osfm_stats_dir = os.path.join(tree.opensfm, "stats") diagram_tiff = os.path.join(osfm_stats_dir, "overlap.tif") diagram_png = os.path.join(osfm_stats_dir, "overlap.png") width = bounds.get('maxx') - bounds.get('minx') height = bounds.get('maxy') - bounds.get('miny') max_dim = max(width, height) resolution = float(max_dim) / float(image_target_size) radius = resolution * math.sqrt(2) # Larger radius for sparse point cloud diagram if not odm_stats['point_cloud_statistics']['dense']: radius *= 10 system.run("pdal translate -i \"{}\" " "-o \"{}\" " "--writer gdal " "--writers.gdal.resolution={} " "--writers.gdal.data_type=uint8_t " "--writers.gdal.dimension={} " "--writers.gdal.output_type=max " "--writers.gdal.radius={} ".format(point_cloud_file, diagram_tiff, resolution, views_dimension, radius)) report_assets = os.path.abspath(os.path.join(os.path.dirname(__file__), "../opendm/report")) overlap_color_map = os.path.join(report_assets, "overlap_color_map.txt") bounds_file_path = os.path.join(tree.odm_georeferencing, 'odm_georeferenced_model.bounds.gpkg') if (args.crop > 0 or args.boundary) and os.path.isfile(bounds_file_path): Cropper.crop(bounds_file_path, diagram_tiff, get_orthophoto_vars(args), keep_original=False) system.run("gdaldem color-relief \"{}\" \"{}\" \"{}\" -of PNG -alpha".format(diagram_tiff, overlap_color_map, diagram_png)) # Copy assets for asset in ["overlap_diagram_legend.png", "dsm_gradient.png"]: shutil.copy(os.path.join(report_assets, asset), os.path.join(osfm_stats_dir, asset)) # Generate previews of ortho/dsm if os.path.isfile(tree.odm_orthophoto_tif): osfm_ortho = os.path.join(osfm_stats_dir, "ortho.png") generate_png(tree.odm_orthophoto_tif, osfm_ortho, image_target_size) dems = [] if args.dsm: dems.append("dsm") if args.dtm: dems.append("dtm") for dem in dems: dem_file = tree.path("odm_dem", "%s.tif" % dem) if os.path.isfile(dem_file): # Resize first (faster) resized_dem_file = io.related_file_path(dem_file, postfix=".preview") system.run("gdal_translate -outsize {} 0 \"{}\" \"{}\" --config GDAL_CACHEMAX {}%".format(image_target_size, dem_file, resized_dem_file, get_max_memory())) log.ODM_INFO("Computing raster stats for %s" % resized_dem_file) dem_stats = get_raster_stats(resized_dem_file) if len(dem_stats) > 0: odm_stats[dem + '_statistics'] = dem_stats[0] osfm_dem = os.path.join(osfm_stats_dir, "%s.png" % dem) colored_dem, hillshade_dem, colored_hillshade_dem = generate_colored_hillshade(resized_dem_file) system.run("gdal_translate -outsize {} 0 -of png \"{}\" \"{}\" --config GDAL_CACHEMAX {}%".format(image_target_size, colored_hillshade_dem, osfm_dem, get_max_memory())) for f in [resized_dem_file, colored_dem, hillshade_dem, colored_hillshade_dem]: if os.path.isfile(f): os.remove(f) else: log.ODM_WARNING("Cannot generate overlap diagram, cannot compute point cloud bounds") else: log.ODM_WARNING("Cannot generate overlap diagram, point cloud stats missing") octx.export_report(os.path.join(tree.odm_report, "report.pdf"), odm_stats, self.rerun())