diff --git a/opendm/config.py b/opendm/config.py index 15492af2..99e9520f 100755 --- a/opendm/config.py +++ b/opendm/config.py @@ -236,35 +236,6 @@ def config(argv=None, parser=None): 'but produce denser point clouds. ' 'Default: %(default)s')) - parser.add_argument('--opensfm-depthmap-min-consistent-views', - metavar='', - action=StoreValue, - type=int, - default=3, - help=('Minimum number of views that should reconstruct a point for it to be valid. Use lower values ' - 'if your images have less overlap. Lower values result in denser point clouds ' - 'but with more noise. ' - 'Default: %(default)s')) - - parser.add_argument('--opensfm-depthmap-method', - metavar='', - action=StoreValue, - default='PATCH_MATCH', - choices=['PATCH_MATCH', 'BRUTE_FORCE', 'PATCH_MATCH_SAMPLE'], - help=('Raw depthmap computation algorithm. ' - 'PATCH_MATCH and PATCH_MATCH_SAMPLE are faster, but might miss some valid points. ' - 'BRUTE_FORCE takes longer but produces denser reconstructions. ' - 'Default: %(default)s')) - - parser.add_argument('--opensfm-depthmap-min-patch-sd', - metavar='', - action=StoreValue, - type=float, - default=1, - help=('When using PATCH_MATCH or PATCH_MATCH_SAMPLE, controls the standard deviation threshold to include patches. ' - 'Patches with lower standard deviation are ignored. ' - 'Default: %(default)s')) - parser.add_argument('--use-hybrid-bundle-adjustment', action=StoreTrue, nargs=0, @@ -284,12 +255,6 @@ def config(argv=None, parser=None): default=False, help='Skip generation of a full 3D model. This can save time if you only need 2D results such as orthophotos and DEMs. Default: %(default)s') - parser.add_argument('--use-opensfm-dense', - action=StoreTrue, - nargs=0, - default=False, - help='Use OpenSfM to compute the dense point cloud instead of OpenMVS. Default: %(default)s') - parser.add_argument('--ignore-gsd', action=StoreTrue, nargs=0, diff --git a/opendm/osfm.py b/opendm/osfm.py index 99fa7d75..d226df98 100644 --- a/opendm/osfm.py +++ b/opendm/osfm.py @@ -185,10 +185,6 @@ class OSFMContext: "processes: %s" % args.max_concurrency, "matching_gps_neighbors: %s" % args.matcher_neighbors, "matching_gps_distance: %s" % args.matcher_distance, - "depthmap_method: %s" % args.opensfm_depthmap_method, - "depthmap_resolution: %s" % depthmap_resolution, - "depthmap_min_patch_sd: %s" % args.opensfm_depthmap_min_patch_sd, - "depthmap_min_consistent_views: %s" % args.opensfm_depthmap_min_consistent_views, "optimize_camera_parameters: %s" % ('no' if args.use_fixed_camera_params or args.cameras else 'yes'), "undistorted_image_format: tif", "bundle_outlier_filtering_type: AUTO", diff --git a/opendm/report/overlap_color_map.txt b/opendm/report/overlap_color_map.txt new file mode 100644 index 00000000..924fc9b0 --- /dev/null +++ b/opendm/report/overlap_color_map.txt @@ -0,0 +1,7 @@ +# QGIS Generated Color Map Export File +2,215,25,28,255,2 +3,246,201,5,255,3 +4,117,188,39,255,4 +5,26,150,65,255,5+ +inf,26,150,65,255,> 5 +nv 0 0 0 0 diff --git a/opendm/report/overlap_diagram_legend.png b/opendm/report/overlap_diagram_legend.png new file mode 100644 index 00000000..6136e438 Binary files /dev/null and b/opendm/report/overlap_diagram_legend.png differ diff --git a/opendm/types.py b/opendm/types.py index 10b3b76d..39e1814c 100644 --- a/opendm/types.py +++ b/opendm/types.py @@ -243,7 +243,6 @@ class ODM_Tree(object): self.opensfm_image_list = os.path.join(self.opensfm, 'image_list.txt') self.opensfm_reconstruction = os.path.join(self.opensfm, 'reconstruction.json') self.opensfm_reconstruction_nvm = os.path.join(self.opensfm, 'undistorted/reconstruction.nvm') - self.opensfm_model = os.path.join(self.opensfm, 'undistorted/depthmaps/merged.ply') self.opensfm_transformation = os.path.join(self.opensfm, 'geocoords_transformation.txt') # OpenMVS diff --git a/stages/dataset.py b/stages/dataset.py index 809ee49b..4dbdc195 100644 --- a/stages/dataset.py +++ b/stages/dataset.py @@ -135,22 +135,6 @@ class ODMLoadDatasetStage(types.ODM_Stage): log.ODM_INFO('Found %s usable images' % len(photos)) - # TODO: add support for masks in OpenMVS - has_mask = False - for p in photos: - if p.mask is not None: - has_mask = True - break - - if has_mask and not args.use_opensfm_dense and not args.fast_orthophoto: - log.ODM_WARNING("Image masks found, will use OpenSfM for dense reconstruction") - args.use_opensfm_dense = True - - # Remove OpenMVS from pipeline. Yep. - opensfm_stage = self.next_stage.next_stage.next_stage - opensfm_stage.next_stage = opensfm_stage.next_stage.next_stage - opensfm_stage.next_stage.prev_stage = opensfm_stage - # Create reconstruction object reconstruction = types.ODM_Reconstruction(photos) diff --git a/stages/odm_app.py b/stages/odm_app.py index 7b14c080..e6c5cee5 100644 --- a/stages/odm_app.py +++ b/stages/odm_app.py @@ -65,7 +65,7 @@ class ODMApp: .connect(merge) \ .connect(opensfm) - if args.use_opensfm_dense or args.fast_orthophoto: + if args.fast_orthophoto: opensfm.connect(filterpoints) else: opensfm.connect(openmvs) \ diff --git a/stages/odm_filterpoints.py b/stages/odm_filterpoints.py index 418b0e93..98902e59 100644 --- a/stages/odm_filterpoints.py +++ b/stages/odm_filterpoints.py @@ -18,8 +18,6 @@ class ODMFilterPoints(types.ODM_Stage): if not io.file_exists(tree.filtered_point_cloud) or self.rerun(): if args.fast_orthophoto: inputPointCloud = os.path.join(tree.opensfm, 'reconstruction.ply') - elif args.use_opensfm_dense: - inputPointCloud = tree.opensfm_model else: inputPointCloud = tree.openmvs_model diff --git a/stages/odm_report.py b/stages/odm_report.py index cebb46e5..861b0014 100644 --- a/stages/odm_report.py +++ b/stages/odm_report.py @@ -1,4 +1,7 @@ import os +import json +import math +import shutil from opendm import log from opendm import io @@ -8,7 +11,6 @@ from opendm.shots import get_geojson_shots_from_opensfm from opendm.osfm import OSFMContext from opendm import gsd from opendm.point_cloud import export_summary_json -import json def hms(seconds): @@ -23,6 +25,14 @@ def hms(seconds): return '{}s'.format(s) +def generate_point_cloud_stats(input_point_cloud, pc_summary_file): + if not os.path.exists(pc_summary_file): + export_summary_json(input_point_cloud, pc_summary_file) + + if os.path.exists(pc_summary_file): + with open(pc_summary_file, 'r') as f: + return json.loads(f.read()) + class ODMReport(types.ODM_Stage): def process(self, args, outputs): tree = outputs['tree'] @@ -56,6 +66,8 @@ class ODMReport(types.ODM_Stage): octx = OSFMContext(tree.opensfm) osfm_stats_json = octx.path("stats", "stats.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): @@ -63,15 +75,25 @@ class ODMReport(types.ODM_Stage): odm_stats = json.loads(f.read()) # Add point cloud stats - pc_summary_file = os.path.join(tree.odm_georeferencing, "odm_georeferenced_model.summary.json") - - # This should have been generated by cropper, but in case it hasn't.. - if not os.path.exists(pc_summary_file) and os.path.exists(tree.odm_georeferencing_model_laz): - export_summary_json(tree.odm_georeferencing_model_laz, pc_summary_file) - - if os.path.exists(pc_summary_file): - with open(pc_summary_file, 'r') as f: - odm_stats['point_cloud_statistics'] = json.loads(f.read()) + if os.path.exists(tree.odm_georeferencing_model_laz): + point_cloud_file = tree.odm_georeferencing_model_laz + views_dimension = "UserData" + + # pc_summary_file should have been generated by cropper + pc_summary_file = os.path.join(tree.odm_georeferencing, "odm_georeferenced_model.summary.json") + odm_stats['point_cloud_statistics'] = generate_point_cloud_stats(tree.odm_georeferencing_model_laz, pc_summary_file) + 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_summary_file = os.path.join(tree.odm_filterpoints, "point_cloud.summary.json") + odm_stats['point_cloud_statistics'] = generate_point_cloud_stats(ply_pc, pc_summary_file) + else: + log.ODM_WARNING("No point cloud found") + + odm_stats['point_cloud_statistics']['dense'] = not args.fast_orthophoto # Add runtime stats odm_stats['odm_processing_statistics'] = { @@ -89,4 +111,43 @@ class ODMReport(types.ODM_Stage): 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('summary', {}).get('bounds') + if bounds: + diagram_target_size = 1600 # 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(diagram_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") + system.run("gdaldem color-relief \"{}\" \"{}\" \"{}\" -of PNG -alpha".format(diagram_tiff, overlap_color_map, diagram_png)) + + # Copy legend + shutil.copy(os.path.join(report_assets, "overlap_diagram_legend.png"), os.path.join(osfm_stats_dir, "overlap_diagram_legend.png")) + 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()) \ No newline at end of file diff --git a/stages/openmvs.py b/stages/openmvs.py index 0b16f05c..0edb5d35 100644 --- a/stages/openmvs.py +++ b/stages/openmvs.py @@ -55,6 +55,8 @@ class ODMOpenMVSStage(types.ODM_Stage): log.ODM_INFO("Running dense reconstruction. This might take a while.") + # TODO: add support for image masks + system.run('%s "%s" %s' % (context.omvs_densify_path, os.path.join(tree.openmvs, 'scene.mvs'), ' '.join(config))) diff --git a/stages/run_opensfm.py b/stages/run_opensfm.py index 4189e3b7..125606f1 100644 --- a/stages/run_opensfm.py +++ b/stages/run_opensfm.py @@ -193,14 +193,6 @@ class ODMOpenSfMStage(types.ODM_Stage): else: log.ODM_WARNING("Found a valid PLY reconstruction in %s" % output_file) - elif args.use_opensfm_dense: - output_file = tree.opensfm_model - - if not io.file_exists(output_file) or self.rerun(): - octx.run('compute_depthmaps') - else: - log.ODM_WARNING("Found a valid dense reconstruction in %s" % output_file) - self.update_progress(90) if reconstruction.is_georeferenced() and (not io.file_exists(tree.opensfm_transformation) or self.rerun()): @@ -222,7 +214,7 @@ class ODMOpenSfMStage(types.ODM_Stage): os.remove(f) # Keep these if using OpenMVS - if args.fast_orthophoto or args.use_opensfm_dense: + if args.fast_orthophoto: files = [octx.path("undistorted", "tracks.csv"), octx.path("undistorted", "reconstruction.json") ]