diff --git a/SuperBuild/CMakeLists.txt b/SuperBuild/CMakeLists.txt index a5a1200b..fc6576b1 100644 --- a/SuperBuild/CMakeLists.txt +++ b/SuperBuild/CMakeLists.txt @@ -138,17 +138,14 @@ set(custom_libs OpenSfM LASzip PDAL Untwine + Entwine MvsTexturing OpenMVS FPCFilter PyPopsift + Obj2Tiles ) -# Build entwine only on Linux -if (NOT WIN32) - set(custom_libs ${custom_libs} Entwine) -endif() - externalproject_add(mve GIT_REPOSITORY https://github.com/OpenDroneMap/mve.git GIT_TAG 262 diff --git a/SuperBuild/cmake/External-Entwine.cmake b/SuperBuild/cmake/External-Entwine.cmake index 866ea89c..5d52e879 100644 --- a/SuperBuild/cmake/External-Entwine.cmake +++ b/SuperBuild/cmake/External-Entwine.cmake @@ -13,7 +13,7 @@ ExternalProject_Add(${_proj_name} #--Download step-------------- DOWNLOAD_DIR ${SB_DOWNLOAD_DIR} GIT_REPOSITORY https://github.com/OpenDroneMap/entwine/ - GIT_TAG 250 + GIT_TAG 285 #--Update/Patch step---------- UPDATE_COMMAND "" #--Configure step------------- diff --git a/SuperBuild/cmake/External-Obj2Tiles.cmake b/SuperBuild/cmake/External-Obj2Tiles.cmake new file mode 100644 index 00000000..53b86913 --- /dev/null +++ b/SuperBuild/cmake/External-Obj2Tiles.cmake @@ -0,0 +1,33 @@ +set(_proj_name obj2tiles) +set(_SB_BINARY_DIR "${SB_BINARY_DIR}/${_proj_name}") + +set(OBJ2TILES_VERSION v1.0.7) +set(OBJ2TILES_EXT "") + +set(OBJ2TILES_ARCH "Linux64") +if (WIN32) + set(OBJ2TILES_ARCH "Win64") + set(OBJ2TILES_EXT ".exe") +elseif(${CMAKE_SYSTEM_PROCESSOR} STREQUAL "aarch64") + set(OBJ2TILES_ARCH "LinuxArm") +endif() + + +ExternalProject_Add(${_proj_name} + PREFIX ${_SB_BINARY_DIR} + TMP_DIR ${_SB_BINARY_DIR}/tmp + STAMP_DIR ${_SB_BINARY_DIR}/stamp + #--Download step-------------- + DOWNLOAD_DIR ${SB_DOWNLOAD_DIR} + URL https://github.com/OpenDroneMap/Obj2Tiles/releases/download/${OBJ2TILES_VERSION}/Obj2Tiles-${OBJ2TILES_ARCH}.zip + SOURCE_DIR ${SB_SOURCE_DIR}/${_proj_name} + UPDATE_COMMAND "" + CONFIGURE_COMMAND "" + BUILD_IN_SOURCE 1 + BUILD_COMMAND "" + INSTALL_COMMAND ${CMAKE_COMMAND} -E copy ${SB_SOURCE_DIR}/${_proj_name}/Obj2Tiles${OBJ2TILES_EXT} ${SB_INSTALL_DIR}/bin + #--Output logging------------- + LOG_DOWNLOAD OFF + LOG_CONFIGURE OFF + LOG_BUILD OFF +) \ No newline at end of file diff --git a/SuperBuild/cmake/External-OpenMVS.cmake b/SuperBuild/cmake/External-OpenMVS.cmake index cbcc640a..1d683314 100644 --- a/SuperBuild/cmake/External-OpenMVS.cmake +++ b/SuperBuild/cmake/External-OpenMVS.cmake @@ -3,7 +3,7 @@ set(_SB_BINARY_DIR "${SB_BINARY_DIR}/${_proj_name}") externalproject_add(vcg GIT_REPOSITORY https://github.com/OpenDroneMap/VCG.git - GIT_TAG 280 + GIT_TAG 285 UPDATE_COMMAND "" SOURCE_DIR ${SB_SOURCE_DIR}/vcg CONFIGURE_COMMAND "" @@ -52,7 +52,7 @@ ExternalProject_Add(${_proj_name} #--Download step-------------- DOWNLOAD_DIR ${SB_DOWNLOAD_DIR} GIT_REPOSITORY https://github.com/OpenDroneMap/openMVS - GIT_TAG 270 + GIT_TAG 285 #--Update/Patch step---------- UPDATE_COMMAND "" #--Configure step------------- diff --git a/VERSION b/VERSION index 2701a226..766d7080 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.4 +2.8.5 diff --git a/opendm/config.py b/opendm/config.py index 8b4d8dcf..7c21f2c0 100755 --- a/opendm/config.py +++ b/opendm/config.py @@ -269,7 +269,13 @@ def config(argv=None, parser=None): '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. Default: %(default)s') - + + parser.add_argument('--no-gpu', + action=StoreTrue, + nargs=0, + default=False, + help='Do not use GPU acceleration, even if it\'s available. Default: %(default)s') + parser.add_argument('--mesh-size', metavar='', action=StoreValue, @@ -608,6 +614,12 @@ def config(argv=None, parser=None): 'suitable for viewers like Leaflet or OpenLayers. ' 'Default: %(default)s') + parser.add_argument('--3d-tiles', + action=StoreTrue, + nargs=0, + default=False, + help='Generate OGC 3D Tiles outputs. Default: %(default)s') + parser.add_argument('--build-overviews', action=StoreTrue, nargs=0, @@ -773,9 +785,6 @@ def config(argv=None, parser=None): if args.fast_orthophoto: log.ODM_INFO('Fast orthophoto is turned on, automatically setting --skip-3dmodel') args.skip_3dmodel = True - # if not 'sfm_algorithm_is_set' in args: - # log.ODM_INFO('Fast orthophoto is turned on, automatically setting --sfm-algorithm to triangulation') - # args.sfm_algorithm = 'triangulation' if args.pc_rectify and not args.pc_classify: log.ODM_INFO("Ground rectify is turned on, automatically turning on point cloud classification") diff --git a/opendm/dem/commands.py b/opendm/dem/commands.py index a1d09bf0..f0edba5d 100755 --- a/opendm/dem/commands.py +++ b/opendm/dem/commands.py @@ -23,6 +23,16 @@ import threading from .ground_rectification.rectify import run_rectification from . import pdal +try: + # GDAL >= 3.3 + from osgeo_utils.gdal_proximity import main as gdal_proximity +except ModuleNotFoundError: + # GDAL <= 3.2 + try: + from osgeo.utils.gdal_proximity import main as gdal_proximity + except: + pass + def classify(lasFile, scalar, slope, threshold, window, verbose=False): start = datetime.now() @@ -290,12 +300,20 @@ def compute_euclidean_map(geotiff_path, output_path, overwrite=False): if not os.path.exists(output_path) or overwrite: log.ODM_INFO("Computing euclidean distance: %s" % output_path) - run('gdal_proximity.py "%s" "%s" -values %s' % (geotiff_path, output_path, nodata)) - if os.path.exists(output_path): - return output_path + if gdal_proximity is not None: + try: + gdal_proximity(['gdal_proximity.py', geotiff_path, output_path, '-values', str(nodata)]) + except Exception as e: + log.ODM_WARNING("Cannot compute euclidean distance: %s" % str(e)) + + if os.path.exists(output_path): + return output_path + else: + log.ODM_WARNING("Cannot compute euclidean distance file: %s" % output_path) else: - log.ODM_WARNING("Cannot compute euclidean distance file: %s" % output_path) + log.ODM_WARNING("Cannot compute euclidean map, gdal_proximity is missing") + else: log.ODM_INFO("Found a euclidean distance map: %s" % output_path) return output_path diff --git a/opendm/entwine.py b/opendm/entwine.py index 265e4fd9..e430d867 100644 --- a/opendm/entwine.py +++ b/opendm/entwine.py @@ -28,34 +28,28 @@ def build(input_point_cloud_files, output_path, max_concurrency=8, rerun=False): if rerun: dir_cleanup() - # On Windows we always use Untwine - if sys.platform == 'win32': + # Attempt with entwine (faster, more memory hungry) + try: + build_entwine(input_point_cloud_files, tmpdir, output_path, max_concurrency=max_concurrency) + except Exception as e: + log.ODM_WARNING("Cannot build EPT using entwine (%s), attempting with untwine..." % str(e)) + dir_cleanup() build_untwine(input_point_cloud_files, tmpdir, output_path, max_concurrency=max_concurrency) - else: - # Attempt with entwine (faster, more memory hungry) - try: - build_entwine(input_point_cloud_files, tmpdir, output_path, max_concurrency=max_concurrency) - except Exception as e: - log.ODM_WARNING("Cannot build EPT using entwine (%s), attempting with untwine..." % str(e)) - dir_cleanup() - build_untwine(input_point_cloud_files, tmpdir, output_path, max_concurrency=max_concurrency) if os.path.exists(tmpdir): shutil.rmtree(tmpdir) -def build_entwine(input_point_cloud_files, tmpdir, output_path, max_concurrency=8): +def build_entwine(input_point_cloud_files, tmpdir, output_path, max_concurrency=8, reproject=None): kwargs = { 'threads': max_concurrency, 'tmpdir': tmpdir, 'all_inputs': "-i " + " ".join(map(double_quote, input_point_cloud_files)), - 'outputdir': output_path + 'outputdir': output_path, + 'reproject': (" -r %s " % reproject) if reproject is not None else "" } - # for _ in range(len(input_point_cloud_files)): - # # One at a time - # system.run('entwine build --threads {threads} --tmp "{tmpdir}" -i "{input}" -o "{outputdir}"'.format(**kwargs)) - system.run('entwine build --threads {threads} --tmp "{tmpdir}" {all_inputs} -o "{outputdir}"'.format(**kwargs)) + system.run('entwine build --threads {threads} --tmp "{tmpdir}" {all_inputs} -o "{outputdir}" {reproject}'.format(**kwargs)) def build_untwine(input_point_cloud_files, tmpdir, output_path, max_concurrency=8, rerun=False): kwargs = { diff --git a/opendm/gpu.py b/opendm/gpu.py index 97b2e02d..f3b9e1b8 100644 --- a/opendm/gpu.py +++ b/opendm/gpu.py @@ -5,7 +5,7 @@ import ctypes from opendm import log from repoze.lru import lru_cache -def gpu_disabled_by_user(): +def gpu_disabled_by_user_env(): return bool(os.environ.get('ODM_NO_GPU')) @lru_cache(maxsize=None) @@ -68,11 +68,13 @@ def get_cuda_compute_version(device_id = 0): return (compute_major.value, compute_minor.value) -@lru_cache(maxsize=None) -def has_gpu(): - if gpu_disabled_by_user(): +def has_gpu(args): + if gpu_disabled_by_user_env(): log.ODM_INFO("Disabling GPU features (ODM_NO_GPU is set)") return False + if args.no_gpu: + log.ODM_INFO("Disabling GPU features (--no-gpu is set)") + return False if sys.platform == 'win32': nvcuda_path = os.path.join(os.environ.get('SYSTEMROOT'), 'system32', 'nvcuda.dll') diff --git a/opendm/ogctiles.py b/opendm/ogctiles.py new file mode 100644 index 00000000..de408981 --- /dev/null +++ b/opendm/ogctiles.py @@ -0,0 +1,135 @@ +import os +import sys +import shutil +import json +import math +from opendm.utils import double_quote +from opendm import io +from opendm import log +from opendm import system +from opendm.entwine import build_entwine +import fiona +from shapely.geometry import shape + +def build_textured_model(input_obj, output_path, reference_lla = None, model_bounds_file=None, rerun=False): + if not os.path.isfile(input_obj): + log.ODM_WARNING("No input OBJ file to process") + return + + if rerun and io.dir_exists(output_path): + log.ODM_WARNING("Removing previous 3D tiles directory: %s" % output_path) + shutil.rmtree(output_path) + + log.ODM_INFO("Generating OGC 3D Tiles textured model") + lat = lon = alt = 0 + + # Read reference_lla.json (if provided) + if reference_lla is not None and os.path.isfile(reference_lla): + try: + with open(reference_lla) as f: + reference_lla = json.loads(f.read()) + lat = reference_lla['latitude'] + lon = reference_lla['longitude'] + alt = reference_lla['altitude'] + except Exception as e: + log.ODM_WARNING("Cannot read %s: %s" % (reference_lla, str(e))) + + # Read model bounds (if provided) + divisions = 1 # default + DIV_THRESHOLD = 10000 # m^2 (this is somewhat arbitrary) + + if model_bounds_file is not None and os.path.isfile(model_bounds_file): + try: + with fiona.open(model_bounds_file, 'r') as f: + if len(f) == 1: + poly = shape(f[1]['geometry']) + area = poly.area + log.ODM_INFO("Approximate area: %s m^2" % round(area, 2)) + + if area < DIV_THRESHOLD: + divisions = 0 + else: + divisions = math.ceil(math.log((area / DIV_THRESHOLD), 4)) + else: + log.ODM_WARNING("Invalid boundary file: %s" % model_bounds_file) + except Exception as e: + log.ODM_WARNING("Cannot read %s: %s" % (model_bounds_file, str(e))) + + try: + kwargs = { + 'input': input_obj, + 'output': output_path, + 'divisions': divisions, + 'lat': lat, + 'lon': lon, + 'alt': alt, + } + system.run('Obj2Tiles "{input}" "{output}" --divisions {divisions} '.format(**kwargs)) + + except Exception as e: + log.ODM_WARNING("Cannot build 3D tiles textured model: %s" % str(e)) + +def build_pointcloud(input_pointcloud, output_path, max_concurrency, rerun=False): + if not os.path.isfile(input_pointcloud): + log.ODM_WARNING("No input point cloud file to process") + return + + if rerun and io.dir_exists(output_path): + log.ODM_WARNING("Removing previous 3D tiles directory: %s" % output_path) + shutil.rmtree(output_path) + + log.ODM_INFO("Generating OGC 3D Tiles point cloud") + + try: + if not os.path.isdir(output_path): + os.mkdir(output_path) + + tmpdir = os.path.join(output_path, "tmp") + entwine_output = os.path.join(output_path, "entwine") + + build_entwine([input_pointcloud], tmpdir, entwine_output, max_concurrency, "EPSG:4978") + + kwargs = { + 'input': entwine_output, + 'output': output_path, + } + system.run('entwine convert -i "{input}" -o "{output}"'.format(**kwargs)) + + for d in [tmpdir, entwine_output]: + if os.path.isdir(d): + shutil.rmtree(d) + except Exception as e: + log.ODM_WARNING("Cannot build 3D tiles point cloud: %s" % str(e)) + + +def build_3dtiles(args, tree, reconstruction, rerun=False): + tiles_output_path = tree.ogc_tiles + model_output_path = os.path.join(tiles_output_path, "model") + pointcloud_output_path = os.path.join(tiles_output_path, "pointcloud") + + if rerun and os.path.exists(tiles_output_path): + shutil.rmtree(tiles_output_path) + + if not os.path.isdir(tiles_output_path): + os.mkdir(tiles_output_path) + + # Model + + if not os.path.isdir(model_output_path) or rerun: + reference_lla = os.path.join(tree.opensfm, "reference_lla.json") + model_bounds_file = os.path.join(tree.odm_georeferencing, 'odm_georeferenced_model.bounds.gpkg') + + input_obj = os.path.join(tree.odm_texturing, tree.odm_textured_model_obj) + if not os.path.isfile(input_obj): + input_obj = os.path.join(tree.odm_25dtexturing, tree.odm_textured_model_obj) + + build_textured_model(input_obj, model_output_path, reference_lla, model_bounds_file, rerun) + else: + log.ODM_WARNING("OGC 3D Tiles model %s already generated" % model_output_path) + + # Point cloud + + if not os.path.isdir(pointcloud_output_path) or rerun: + build_pointcloud(tree.odm_georeferencing_model_laz, pointcloud_output_path, args.max_concurrency, rerun) + else: + log.ODM_WARNING("OGC 3D Tiles model %s already generated" % model_output_path) \ No newline at end of file diff --git a/opendm/osfm.py b/opendm/osfm.py index 7a74295d..7623b90a 100644 --- a/opendm/osfm.py +++ b/opendm/osfm.py @@ -251,7 +251,7 @@ class OSFMContext: config.append("matcher_type: %s" % osfm_matchers[matcher_type]) # GPU acceleration? - if has_gpu(): + if has_gpu(args): max_photo = find_largest_photo(photos) w, h = max_photo.width, max_photo.height if w > h: diff --git a/opendm/photo.py b/opendm/photo.py index 4d1f97cc..d999b3f6 100644 --- a/opendm/photo.py +++ b/opendm/photo.py @@ -81,6 +81,11 @@ def get_mm_per_unit(resolution_unit): class PhotoCorruptedException(Exception): pass +class GPSRefMock: + def __init__(self, ref): + self.values = [ref] + + class ODM_Photo: """ODMPhoto - a class for ODMPhotos""" @@ -209,8 +214,14 @@ class ODM_Photo: self.altitude *= -1 if 'GPS GPSLatitude' in tags and 'GPS GPSLatitudeRef' in tags: self.latitude = self.dms_to_decimal(tags['GPS GPSLatitude'], tags['GPS GPSLatitudeRef']) + elif 'GPS GPSLatitude' in tags: + log.ODM_WARNING("GPS position for %s might be incorrect, GPSLatitudeRef tag is missing (assuming N)" % self.filename) + self.latitude = self.dms_to_decimal(tags['GPS GPSLatitude'], GPSRefMock('N')) if 'GPS GPSLongitude' in tags and 'GPS GPSLongitudeRef' in tags: self.longitude = self.dms_to_decimal(tags['GPS GPSLongitude'], tags['GPS GPSLongitudeRef']) + elif 'GPS GPSLongitude' in tags: + log.ODM_WARNING("GPS position for %s might be incorrect, GPSLongitudeRef tag is missing (assuming E)" % self.filename) + self.longitude = self.dms_to_decimal(tags['GPS GPSLongitude'], GPSRefMock('E')) if 'Image Orientation' in tags: self.orientation = self.int_value(tags['Image Orientation']) except (IndexError, ValueError) as e: diff --git a/opendm/types.py b/opendm/types.py index b49f7466..7ce99321 100644 --- a/opendm/types.py +++ b/opendm/types.py @@ -289,6 +289,7 @@ class ODM_Tree(object): # Tiles self.entwine_pointcloud = self.path("entwine_pointcloud") + self.ogc_tiles = self.path("3d_tiles") def path(self, *args): return os.path.join(self.root_path, *args) diff --git a/opendm/utils.py b/opendm/utils.py index c01dfe8a..2be031ad 100644 --- a/opendm/utils.py +++ b/opendm/utils.py @@ -64,6 +64,7 @@ def get_processing_results_paths(): "dsm_tiles", "dtm_tiles", "orthophoto_tiles", + "3d_tiles", "images.json", "cameras.json", "log.json", diff --git a/stages/odm_postprocess.py b/stages/odm_postprocess.py index 703b6586..95fac779 100644 --- a/stages/odm_postprocess.py +++ b/stages/odm_postprocess.py @@ -5,6 +5,7 @@ from opendm import io from opendm import log from opendm import types from opendm.utils import copy_paths, get_processing_results_paths +from opendm.ogctiles import build_3dtiles class ODMPostProcess(types.ODM_Stage): def process(self, args, outputs): @@ -43,7 +44,10 @@ class ODMPostProcess(types.ODM_Stage): break else: log.ODM_WARNING("Cannot open %s for writing, skipping GCP embedding" % product) - + + if getattr(args, '3d_tiles'): + build_3dtiles(args, tree, reconstruction, self.rerun()) + if args.copy_to: try: copy_paths([os.path.join(args.project_path, p) for p in get_processing_results_paths()], args.copy_to, self.rerun()) diff --git a/stages/openmvs.py b/stages/openmvs.py index 6594c158..02c4ec5e 100644 --- a/stages/openmvs.py +++ b/stages/openmvs.py @@ -59,21 +59,25 @@ class ODMOpenMVSStage(types.ODM_Stage): log.ODM_INFO("Estimating depthmaps") number_views_fuse = 2 + densify_ini_file = os.path.join(tree.openmvs, 'Densify.ini') + subres_levels = 2 # The number of lower resolutions to process before estimating output resolution depthmap. config = [ " --resolution-level %s" % int(resolution_level), + '--dense-config-file "%s"' % densify_ini_file, "--min-resolution %s" % depthmap_resolution, "--max-resolution %s" % int(outputs['undist_image_max_size']), "--max-threads %s" % args.max_concurrency, "--number-views-fuse %s" % number_views_fuse, + "--sub-resolution-levels %s" % subres_levels, '-w "%s"' % depthmaps_dir, "-v 0" ] gpu_config = [] - if not has_gpu(): - gpu_config.append("--cuda-device -1") + if not has_gpu(args): + gpu_config.append("--cuda-device -2") if args.pc_tile: config.append("--fusion-mode 1") @@ -81,6 +85,10 @@ class ODMOpenMVSStage(types.ODM_Stage): if not args.pc_geometric: config.append("--geometric-iters 0") + sharp = args.pc_filter > 0 + with open(densify_ini_file, 'w+') as f: + f.write("Optimize = %s\n" % (7 if sharp else 3)) + def run_densify(): system.run('"%s" "%s" %s' % (context.omvs_densify_path, openmvs_scene_file, @@ -93,7 +101,7 @@ class ODMOpenMVSStage(types.ODM_Stage): # try to run it again without GPU if e.errorCode == 1 and len(gpu_config) == 0: log.ODM_WARNING("OpenMVS failed with GPU, is your graphics card driver up to date? Falling back to CPU.") - gpu_config.append("--cuda-device -1") + gpu_config.append("--cuda-device -2") run_densify() else: raise e