diff --git a/Dockerfile b/Dockerfile index 5f2d0378..bb14a6a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ libexiv2-dev liblas-bin python-matplotlib libatlas-base-dev swig2.0 python-wheel RUN apt-get remove libdc1394-22-dev RUN pip install --upgrade pip RUN pip install setuptools -RUN pip install -U PyYAML exifread gpxpy xmltodict catkin-pkg appsettings https://github.com/gipit/gippy/archive/v1.0.0.zip loky shapely numpy pyproj psutil && pip install -U scipy --ignore-installed +RUN pip install -U PyYAML exifread gpxpy xmltodict catkin-pkg appsettings https://github.com/gipit/gippy/archive/v1.0.0.zip loky shapely numpy pyproj psutil repoze.lru && pip install -U scipy --ignore-installed ENV PYTHONPATH="$PYTHONPATH:/code/SuperBuild/install/lib/python2.7/dist-packages" ENV PYTHONPATH="$PYTHONPATH:/code/SuperBuild/src/opensfm" diff --git a/SuperBuild/cmake/External-OpenSfM.cmake b/SuperBuild/cmake/External-OpenSfM.cmake index 7a14bbb4..08b74213 100644 --- a/SuperBuild/cmake/External-OpenSfM.cmake +++ b/SuperBuild/cmake/External-OpenSfM.cmake @@ -8,8 +8,7 @@ ExternalProject_Add(${_proj_name} STAMP_DIR ${_SB_BINARY_DIR}/stamp #--Download step-------------- DOWNLOAD_DIR ${SB_DOWNLOAD_DIR} - URL https://github.com/mapillary/OpenSfM/archive/93ae099862297c36ae94dd56fca1c062aa2bb60d.zip - URL_MD5 f0d8ec8a8dc9e0f6fd55f77d5407e50f + URL https://github.com/pierotofy/OpenSfM/archive/pngscale.zip #--Update/Patch step---------- UPDATE_COMMAND "" #--Configure step------------- diff --git a/configure.sh b/configure.sh index c59108b5..d5c0448a 100755 --- a/configure.sh +++ b/configure.sh @@ -78,7 +78,8 @@ install() { gpxpy \ xmltodict \ appsettings \ - loky + loky \ + repoze.lru echo "Installing Ecto Dependencies" pip install -U catkin-pkg diff --git a/core2.Dockerfile b/core2.Dockerfile index a948c12a..65fe3969 100644 --- a/core2.Dockerfile +++ b/core2.Dockerfile @@ -19,7 +19,7 @@ libexiv2-dev liblas-bin python-matplotlib libatlas-base-dev swig2.0 python-wheel RUN apt-get remove libdc1394-22-dev RUN pip install --upgrade pip RUN pip install setuptools -RUN pip install -U PyYAML exifread gpxpy xmltodict catkin-pkg appsettings https://github.com/gipit/gippy/archive/v1.0.0.zip loky shapely numpy pyproj psutil && pip install -U scipy --ignore-installed +RUN pip install -U PyYAML exifread gpxpy xmltodict catkin-pkg appsettings https://github.com/gipit/gippy/archive/v1.0.0.zip loky shapely numpy pyproj psutil repoze.lru && pip install -U scipy --ignore-installed ENV PYTHONPATH="$PYTHONPATH:/code/SuperBuild/install/lib/python2.7/dist-packages" ENV PYTHONPATH="$PYTHONPATH:/code/SuperBuild/src/opensfm" diff --git a/opendm/config.py b/opendm/config.py index e82dd1c5..ed0736c6 100644 --- a/opendm/config.py +++ b/opendm/config.py @@ -203,6 +203,14 @@ def config(): default=False, help='Use opensfm to compute dense point cloud alternatively') + parser.add_argument('--ignore-gsd', + action='store_true', + default=False, + help='Ignore Ground Sampling Distance (GSD). GSD ' + 'caps the maximum resolution of image outputs and ' + 'resizes images when necessary, resulting in faster processing and ' + 'lower memory usage. Since GSD is an estimate, sometimes ignoring it can result in slightly better image output quality.') + parser.add_argument('--smvs-alpha', metavar='', default=1.0, @@ -540,9 +548,8 @@ def config(): sys.exit(1) if args.fast_orthophoto: - log.ODM_INFO('Fast orthophoto is turned on, automatically setting --skip-3dmodel and --use-opensfm-dense') + log.ODM_INFO('Fast orthophoto is turned on, automatically setting --skip-3dmodel') args.skip_3dmodel = True - args.use_opensfm_dense = True if args.dtm and args.pc_classify == 'none': log.ODM_INFO("DTM is turned on, automatically turning on point cloud classification") diff --git a/opendm/gsd.py b/opendm/gsd.py index 8606fbbd..b754c4b7 100644 --- a/opendm/gsd.py +++ b/opendm/gsd.py @@ -1,31 +1,53 @@ import os import json import numpy as np -import functools +from repoze.lru import lru_cache from opendm import log -def cap_resolution(resolution, reconstruction_json): +def image_scale_factor(target_resolution, reconstruction_json, gsd_error_estimate = 0.1): """ - :param resolution resolution in cm / pixel + :param target_resolution resolution the user wants have in cm / pixel :param reconstruction_json path to OpenSfM's reconstruction.json - :return The max value between resolution and the GSD computed from the reconstruction. - If a GSD cannot be computed, it just returns resolution. Units are in cm / pixel. + :param gsd_error_estimate percentage of estimated error in the GSD calculation to set an upper bound on resolution. + :return A down-scale (<= 1) value to apply to images to achieve the target resolution by comparing the current GSD of the reconstruction. + If a GSD cannot be computed, it just returns 1. Returned scale values are never higher than 1. """ gsd = opensfm_reconstruction_average_gsd(reconstruction_json) + if gsd is not None and target_resolution > 0: + gsd = gsd * (1 + gsd_error_estimate) + return min(1, gsd / target_resolution) + else: + return 1 + + +def cap_resolution(resolution, reconstruction_json, gsd_error_estimate = 0.1, ignore_gsd=False): + """ + :param resolution resolution in cm / pixel + :param reconstruction_json path to OpenSfM's reconstruction.json + :param gsd_error_estimate percentage of estimated error in the GSD calculation to set an upper bound on resolution. + :param ignore_gsd when set to True, forces the function to just return resolution. + :return The max value between resolution and the GSD computed from the reconstruction. + If a GSD cannot be computed, or ignore_gsd is set to True, it just returns resolution. Units are in cm / pixel. + """ + if ignore_gsd: + return resolution + + gsd = opensfm_reconstruction_average_gsd(reconstruction_json) + if gsd is not None: - log.ODM_INFO('Ground Sampling Distance: {} cm / pixel'.format(round(gsd, 2)) + gsd = gsd * (1 - gsd_error_estimate) if gsd > resolution: - log.ODM_WARNING('Maximum resolution set to GSD (requested resolution was {})'.format(round(resolution, 2))) + log.ODM_WARNING('Maximum resolution set to GSD - {}% ({} cm / pixel, requested resolution was {} cm / pixel)'.format(gsd_error_estimate * 100, round(gsd, 2), round(resolution, 2))) return gsd else: return resolution else: - log.ODM_WARNING('Cannot calculate GSD, using requested resolution of {}'.format(round(resolution, 2)) + log.ODM_WARNING('Cannot calculate GSD, using requested resolution of {}'.format(round(resolution, 2))) return resolution -@functools.lru_cache(maxsize=None, typed=False) +@lru_cache(maxsize=None) def opensfm_reconstruction_average_gsd(reconstruction_json): """ Computes the average Ground Sampling Distance of an OpenSfM reconstruction. @@ -62,7 +84,12 @@ def opensfm_reconstruction_average_gsd(reconstruction_json): shot_height - ground_height, camera['width'])) - return np.mean(gsds) if len(gsds) > 0 else None + if len(gsds) > 0: + mean = np.mean(gsds) + if mean > 0: + return mean + + return None def calculate_gsd(sensor_width, flight_height, focal_length, image_width): """ diff --git a/scripts/mvstex.py b/scripts/mvstex.py index 7f261597..6403d1d9 100644 --- a/scripts/mvstex.py +++ b/scripts/mvstex.py @@ -103,15 +103,10 @@ class ODMMvsTexCell(ecto.Cell): 'keepUnseenFaces': keepUnseenFaces, 'toneMapping': self.params.tone_mapping, 'nadirMode': nadir, - 'nadirWeight': 2 ** args.texturing_nadir_weight - 1 + 'nadirWeight': 2 ** args.texturing_nadir_weight - 1, + 'nvm_file': io.join_paths(tree.opensfm, "reconstruction.nvm") } - if args.use_opensfm_dense: - kwargs['nvm_file'] = io.join_paths(tree.opensfm, - "reconstruction.nvm") - else: - kwargs['nvm_file'] = tree.smvs + "::undistorted" - # Make sure tmp directory is empty mvs_tmp_dir = os.path.join(r['out_dir'], 'tmp') if io.dir_exists(mvs_tmp_dir): diff --git a/scripts/odm_app.py b/scripts/odm_app.py index 4b9d5edd..6d9794b6 100644 --- a/scripts/odm_app.py +++ b/scripts/odm_app.py @@ -118,13 +118,14 @@ class ODMApp(ecto.BlackBox): self.args[:] >> self.opensfm['args'], self.dataset['reconstruction'] >> self.opensfm['reconstruction']] - if p.args.use_opensfm_dense: + if p.args.use_opensfm_dense or p.args.fast_orthophoto: # create odm mesh from opensfm point cloud connections += [self.tree[:] >> self.meshing['tree'], self.args[:] >> self.meshing['args'], self.opensfm['reconstruction'] >> self.meshing['reconstruction']] else: # run smvs + connections += [self.tree[:] >> self.smvs['tree'], self.args[:] >> self.smvs['args'], self.opensfm['reconstruction'] >> self.smvs['reconstruction']] diff --git a/scripts/odm_dem.py b/scripts/odm_dem.py index 76e8ed54..418438eb 100644 --- a/scripts/odm_dem.py +++ b/scripts/odm_dem.py @@ -92,7 +92,7 @@ class ODMDEMCell(ecto.Cell): if args.dsm: products.append('dsm') if args.dtm: products.append('dtm') - resolution = gsd.cap_resolution(args.dem_resolution, tree.opensfm_reconstruction) + resolution = gsd.cap_resolution(args.dem_resolution, tree.opensfm_reconstruction, ignore_gsd=args.ignore_gsd) radius_steps = [(resolution / 100.0) / 2.0] for _ in range(args.dem_gapfill_steps - 1): radius_steps.append(radius_steps[-1] * 2) # 2 is arbitrary, maybe there's a better value? diff --git a/scripts/odm_meshing.py b/scripts/odm_meshing.py index 9a8e68bb..c2c58104 100644 --- a/scripts/odm_meshing.py +++ b/scripts/odm_meshing.py @@ -50,10 +50,10 @@ class ODMeshingCell(ecto.Cell): 'odm_meshing' in args.rerun_from) infile = tree.smvs_model - if args.use_opensfm_dense: - infile = tree.opensfm_model - elif args.fast_orthophoto: + if args.fast_orthophoto: infile = os.path.join(tree.opensfm, 'reconstruction.ply') + elif args.use_opensfm_dense: + infile = tree.opensfm_model # Create full 3D model unless --skip-3dmodel is set if not args.skip_3dmodel: @@ -79,7 +79,7 @@ class ODMeshingCell(ecto.Cell): if not io.file_exists(tree.odm_25dmesh) or rerun_cell: log.ODM_DEBUG('Writing ODM 2.5D Mesh file in: %s' % tree.odm_25dmesh) - dsm_resolution = gsd.cap_resolution(args.orthophoto_resolution, tree.opensfm_reconstruction) / 100.0 + dsm_resolution = gsd.cap_resolution(args.orthophoto_resolution, tree.opensfm_reconstruction, ignore_gsd=args.ignore_gsd) / 100.0 # Create reference DSM at half ortho resolution dsm_resolution *= 2 diff --git a/scripts/odm_orthophoto.py b/scripts/odm_orthophoto.py index 438e9c81..29178407 100644 --- a/scripts/odm_orthophoto.py +++ b/scripts/odm_orthophoto.py @@ -57,7 +57,7 @@ class ODMOrthoPhotoCell(ecto.Cell): 'log': tree.odm_orthophoto_log, 'ortho': tree.odm_orthophoto_file, 'corners': tree.odm_orthophoto_corners, - 'res': 1.0 / (gsd.cap_resolution(self.params.resolution, tree.reconstruction_json) / 100.0), + 'res': 1.0 / (gsd.cap_resolution(self.params.resolution, tree.opensfm_reconstruction, ignore_gsd=args.ignore_gsd) / 100.0), 'verbose': verbose } diff --git a/scripts/run_opensfm.py b/scripts/run_opensfm.py index 0c0f8457..346b5987 100644 --- a/scripts/run_opensfm.py +++ b/scripts/run_opensfm.py @@ -4,7 +4,7 @@ from opendm import log from opendm import io from opendm import system from opendm import context - +from opendm import gsd class ODMOpenSfMCell(ecto.Cell): def declare_params(self, params): @@ -50,10 +50,10 @@ class ODMOpenSfMCell(ecto.Cell): (args.rerun_from is not None and 'opensfm' in args.rerun_from) - if args.use_opensfm_dense: + if args.fast_orthophoto: + output_file = io.join_paths(tree.opensfm, 'reconstruction.ply') + elif args.use_opensfm_dense: output_file = tree.opensfm_model - if args.fast_orthophoto: - output_file = io.join_paths(tree.opensfm, 'reconstruction.ply') else: output_file = tree.opensfm_reconstruction @@ -135,25 +135,37 @@ class ODMOpenSfMCell(ecto.Cell): log.ODM_WARNING('Found a valid OpenSfM reconstruction file in: %s' % tree.opensfm_reconstruction) - if args.use_opensfm_dense: - if not io.file_exists(tree.opensfm_reconstruction_nvm) or rerun_cell: - system.run('PYTHONPATH=%s %s/bin/opensfm export_visualsfm %s' % - (context.pyopencv_path, context.opensfm_path, tree.opensfm)) - else: - log.ODM_WARNING('Found a valid OpenSfM NVM reconstruction file in: %s' % - tree.opensfm_reconstruction_nvm) + # Always export VisualSFM's reconstruction and undistort images + # as we'll use these for texturing (after GSD estimation and resizing) + if not args.ignore_gsd: + image_scale = gsd.image_scale_factor(args.orthophoto_resolution, tree.opensfm_reconstruction) + else: + image_scale = 1.0 + if not io.file_exists(tree.opensfm_reconstruction_nvm) or rerun_cell: + system.run('PYTHONPATH=%s %s/bin/opensfm export_visualsfm --image_extension png --scale_focal %s %s' % + (context.pyopencv_path, context.opensfm_path, image_scale, tree.opensfm)) + else: + log.ODM_WARNING('Found a valid OpenSfM NVM reconstruction file in: %s' % + tree.opensfm_reconstruction_nvm) + + # These will be used for texturing + system.run('PYTHONPATH=%s %s/bin/opensfm undistort --image_format png --image_scale %s %s' % + (context.pyopencv_path, context.opensfm_path, image_scale, tree.opensfm)) + + # Skip dense reconstruction if necessary and export + # sparse reconstruction instead + if args.fast_orthophoto: + system.run('PYTHONPATH=%s %s/bin/opensfm export_ply --no-cameras %s' % + (context.pyopencv_path, context.opensfm_path, tree.opensfm)) + elif args.use_opensfm_dense: + # Undistort images at full scale in JPG + # (TODO: we could compare the size of the PNGs if they are < than depthmap_resolution + # and use those instead of re-exporting full resolution JPGs) system.run('PYTHONPATH=%s %s/bin/opensfm undistort %s' % - (context.pyopencv_path, context.opensfm_path, tree.opensfm)) - - # Skip dense reconstruction if necessary and export - # sparse reconstruction instead - if args.fast_orthophoto: - system.run('PYTHONPATH=%s %s/bin/opensfm export_ply --no-cameras %s' % - (context.pyopencv_path, context.opensfm_path, tree.opensfm)) - else: - system.run('PYTHONPATH=%s %s/bin/opensfm compute_depthmaps %s' % - (context.pyopencv_path, context.opensfm_path, tree.opensfm)) + (context.pyopencv_path, context.opensfm_path, tree.opensfm)) + system.run('PYTHONPATH=%s %s/bin/opensfm compute_depthmaps %s' % + (context.pyopencv_path, context.opensfm_path, tree.opensfm)) else: log.ODM_WARNING('Found a valid OpenSfM reconstruction file in: %s' %