Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7559023
build: update OpenSfM to updated fork + fix, remove custom patch
spwoodcock Jul 17, 2026
8692827
fix(opensfm): update opensfm usage to match upgraded fork
spwoodcock Jul 17, 2026
343bea1
build(deps): add rerun-sdk required by updated opensfm
spwoodcock Jul 17, 2026
6c47d7f
build(pixi): add gperftools as prod-only, ensuring it's not in the bu…
spwoodcock Jul 17, 2026
2038f0d
build(pixi): fixes to conda libs and ld library paths, for build / pr…
spwoodcock Jul 17, 2026
436de2b
build(SuperBuild): update to latest FPCFilter for standard RGB PLY he…
spwoodcock Jul 17, 2026
1b3f572
build(SuperBuild): fix ENOENT error for PoissonRecon due to conflict …
spwoodcock Jul 17, 2026
a7b029b
build: replace opensfm with official v1 repo
spwoodcock Jul 18, 2026
b880a83
build(odm): add missed dependencies after opensfm upgrade --> v1
spwoodcock Jul 18, 2026
1953cab
fix(opensfm): compatibility with opensfm v1 changes
spwoodcock Jul 18, 2026
1b03f0b
feat(opensfm): allow passthrough of sfm-algorithm selection + gps acc…
spwoodcock Jul 18, 2026
f67bf28
fix(opensfm): correct passthrough of camera projection type to opensfm
spwoodcock Jul 18, 2026
c84406c
docs: add small note in cmd line about deprecation of planar sfm algo
spwoodcock Jul 18, 2026
1e1d5ef
build(opensfm): additional build dependency: fast_float
spwoodcock Jul 18, 2026
0567f41
build(opensfm): add missed lz4 dep to odm
spwoodcock Jul 18, 2026
d5a9d46
build(opensfm): fix for scikit-build-core usage by opensfm with cmake…
spwoodcock Jul 19, 2026
52a8692
fix(opensfm): shit exported coords over rig instances
spwoodcock Jul 19, 2026
5830ca4
fix(opensfm): send per-axis gps accuracy to opensfm
spwoodcock Jul 19, 2026
ed52bc7
test: add a few tests for axis-aware gps accuracy passthru
spwoodcock Jul 19, 2026
e5e2ae3
fix(opensfm): missed additional deps imported for dense reconstruction
spwoodcock Jul 19, 2026
32e376b
fix(opensfm): moved imageFilter arg in code, write in backward compat…
spwoodcock Jul 19, 2026
3526d47
fix(opensfm): keep cameras aligned with points when exporting geocoords
spwoodcock Jul 19, 2026
07bc3ea
fix(opensfm): fix report generation compatibility (remove custom odm …
spwoodcock Jul 19, 2026
7475967
fix(opensfm): input filenames cannot contain spaces for OpenSfM, so r…
spwoodcock Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions SuperBuild/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,11 @@ if (WIN32)
set (POISSON_BIN_PATH "x64/${CMAKE_BUILD_TYPE}/PoissonRecon.exe")
else()
# Inherit pixi/conda compiler flags (LDFLAGS for -ljpeg, etc.) from the shell environment.
set (POISSON_BUILD_CMD bash -lc "make -j${nproc} poissonrecon")
# Pre-create the output dir: PoissonRecon's Makefile races mkdir vs compile under -j (ENOENT).
set (POISSON_BUILD_CMD bash -lc "mkdir -p Bin/Linux && make -j${nproc} poissonrecon")
set (POISSON_BIN_PATH "Linux/PoissonRecon")
if (APPLE)
set(POISSON_BUILD_CMD bash -lc "make -j${nproc} -f Makefile.macos poissonrecon COMPILER=${CMAKE_CXX_COMPILER} CONDA_PREFIX=$ENV{CONDA_PREFIX}")
set(POISSON_BUILD_CMD bash -lc "mkdir -p Bin/Linux && make -j${nproc} -f Makefile.macos poissonrecon COMPILER=${CMAKE_CXX_COMPILER} CONDA_PREFIX=$ENV{CONDA_PREFIX}")
endif()
endif()
externalproject_add(poissonrecon
Expand Down
2 changes: 1 addition & 1 deletion SuperBuild/cmake/External-FPCFilter.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ ExternalProject_Add(${_proj_name}
#--Download step--------------
DOWNLOAD_DIR ${SB_DOWNLOAD_DIR}
GIT_REPOSITORY https://github.com/OpenDroneMap/FPCFilter
GIT_TAG 331
GIT_TAG c1467cbfa0844bff99525d79232b790e04d2b939
#--Update/Patch step----------
UPDATE_COMMAND ""
#--Configure step-------------
Expand Down
11 changes: 7 additions & 4 deletions SuperBuild/cmake/External-OpenSfM.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,10 @@ ExternalProject_Add(${_proj_name}
STAMP_DIR ${_SB_BINARY_DIR}/stamp
#--Download step--------------
DOWNLOAD_DIR ${SB_DOWNLOAD_DIR}
GIT_REPOSITORY https://github.com/OpenDroneMap/OpenSfM/
GIT_TAG c5328439465e6ace011f39077d1077d7b1cdd65d
GIT_REPOSITORY https://github.com/opensfm/opensfm
GIT_TAG 8fd3214d2a5fd0b2905905761a2a4753b9373c4a
#--Update/Patch step----------
UPDATE_COMMAND git submodule update --init --recursive
PATCH_COMMAND ${CMAKE_COMMAND} -P ${SB_ROOT_DIR}/cmake/apply-patch.cmake ${SB_ROOT_DIR}/cmake/opensfm-aarch64-abs.patch
#--Configure step-------------
SOURCE_DIR ${SB_INSTALL_DIR}/bin/${_proj_name}
CONFIGURE_COMMAND ${CMAKE_COMMAND} <SOURCE_DIR>/${_proj_name}/src
Expand All @@ -63,7 +62,11 @@ ExternalProject_Add(${_proj_name}
#--Build step-----------------
BINARY_DIR ${_SB_BINARY_DIR}
#--Install step---------------
INSTALL_COMMAND ""
# See install-opensfm-modules.cmake for details
INSTALL_COMMAND ${CMAKE_COMMAND}
-DOPENSFM_BINARY_DIR=${_SB_BINARY_DIR}
-DOPENSFM_PACKAGE_DIR=${SB_INSTALL_DIR}/bin/${_proj_name}/opensfm
-P ${CMAKE_CURRENT_LIST_DIR}/install-opensfm-modules.cmake
#--Output logging-------------
LOG_DOWNLOAD OFF
LOG_CONFIGURE OFF
Expand Down
21 changes: 21 additions & 0 deletions SuperBuild/cmake/install-opensfm-modules.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copy OpenSfM's compiled py* extension modules from the build dir into the
# package next to __init__.py so `import opensfm` can find them. scikit-build-core
# would normally do this at packaging time, but we build with raw cmake + make.
# Compatible with linux/win/macOS.
file(GLOB_RECURSE _osfm_mods
"${OPENSFM_BINARY_DIR}/py*.so"
"${OPENSFM_BINARY_DIR}/py*.pyd"
"${OPENSFM_BINARY_DIR}/py*.dylib")

if(NOT _osfm_mods)
message(FATAL_ERROR
"No OpenSfM extension modules (py*.so/.pyd/.dylib) found under "
"${OPENSFM_BINARY_DIR} - cannot make the opensfm package importable.")
endif()

file(COPY ${_osfm_mods} DESTINATION "${OPENSFM_PACKAGE_DIR}")

foreach(_m ${_osfm_mods})
get_filename_component(_name "${_m}" NAME)
message(STATUS "OpenSfM: installed ${_name} -> ${OPENSFM_PACKAGE_DIR}")
endforeach()
13 changes: 0 additions & 13 deletions SuperBuild/cmake/opensfm-aarch64-abs.patch

This file was deleted.

2 changes: 1 addition & 1 deletion opendm/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ def config(argv=None, parser=None):
action=StoreValue,
default='incremental',
choices=['incremental', 'triangulation', 'planar'],
help=('Choose the structure from motion algorithm. For aerial datasets, if camera GPS positions and angles are available, triangulation can generate better results. For planar scenes captured at fixed altitude with nadir-only images, planar can be much faster. '
help=('Choose the structure from motion algorithm. For aerial datasets, if camera GPS positions and angles are available, triangulation can generate better results. The planar option is deprecated (no longer supported by OpenSfM) and is treated as incremental. '
'Can be one of: %(choices)s. Default: '
'%(default)s'))

Expand Down
116 changes: 99 additions & 17 deletions opendm/osfm.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import os, shutil, sys, json, argparse, copy
import yaml
import numpy as np
import pyproj
from pyproj import CRS
from opendm import io
from opendm import log
Expand All @@ -22,8 +21,8 @@
from opensfm import report
from opendm.multispectral import get_photos_by_band
from opendm.gpu import has_popsift_and_can_handle_texsize, has_gpu
from opensfm import multiview, exif
from opensfm.actions.export_geocoords import _transform
from opensfm import multiview, exif, geo


class OSFMContext:
def __init__(self, opensfm_project_path):
Expand All @@ -49,10 +48,16 @@ def create_tracks(self, rerun=False):
else:
log.ODM_WARNING('Found a valid OpenSfM tracks file in: %s' % tracks_file)

def reconstruct(self, rolling_shutter_correct=False, merge_partial=False, rerun=False):
def reconstruct(self, algorithm='incremental', rolling_shutter_correct=False, merge_partial=False, rerun=False):
# Upstream OpenSfM supports 'incremental' and 'triangulation', so map anything
# else (e.g. the deprecated 'planar') to incremental
if algorithm not in ('incremental', 'triangulation'):
log.ODM_WARNING("Unsupported SfM algorithm '%s', using incremental instead" % algorithm)
algorithm = 'incremental'

reconstruction_file = os.path.join(self.opensfm_project_path, 'reconstruction.json')
if not io.file_exists(reconstruction_file) or rerun:
self.run('reconstruct')
self.run('reconstruct --algorithm %s' % algorithm)
if merge_partial:
self.check_merge_partial_reconstructions()
else:
Expand All @@ -70,13 +75,13 @@ def reconstruct(self, rolling_shutter_correct=False, merge_partial=False, rerun=
rs_file = self.path('rs_done.txt')

if not io.file_exists(rs_file) or rerun:
self.run('rs_correct')
self.run('correct_rolling_shutter')

log.ODM_INFO("Re-running the reconstruction pipeline")

self.match_features(True)
self.create_tracks(True)
self.reconstruct(rolling_shutter_correct=False, merge_partial=merge_partial, rerun=True)
self.reconstruct(algorithm=algorithm, rolling_shutter_correct=False, merge_partial=merge_partial, rerun=True)

self.touch(rs_file)
else:
Expand Down Expand Up @@ -126,6 +131,45 @@ def check_merge_partial_reconstructions(self):

data.save_reconstruction([merged])

def export_geocoords(self, proj4, offset_x, offset_y):
"""
Export the reconstruction to map coordinates (e.g. UTM), then shift it
close to the origin.

Map coordinates are large numbers that lose precision in the float-based
MVS and meshing steps, so we subtract a fixed origin to keep values small.
odm_georeferencing adds the origin back for the final outputs. (OpenSfM
used to do this via the removed --offset-x/--offset-y flags.)

We project and shift in one pass rather than calling OpenSfM's
export_geocoords, which saves at full map scale first. The projection
leaves camera rotations slightly non-orthonormal, and OpenSfM
re-orthonormalizes them on reload; at map scale (northings in the
millions) that rounding shifts cameras hundreds of metres off the points.
Subtracting the origin before saving keeps everything at local scale,
where the rounding is negligible.

The shift moves the camera positions and the 3D points. Camera positions
live on rig instances, so we move those, not individual shots: OpenSfM
errors when moving a shot that shares a rig with others (multi-camera,
multispectral, panorama).
"""
geocoords_file = 'reconstruction.geocoords.json'
data = DataSet(self.opensfm_project_path)
reconstructions = data.load_reconstruction()

projection = geo.construct_proj_transformer(proj4, inverse=True)
offset = np.array([offset_x, offset_y, 0.0])
for r in reconstructions:
geo.transform_reconstruction_with_proj(r, projection)
for rig_instance in r.rig_instances.values():
pose = rig_instance.pose
pose.set_origin(pose.get_origin() - offset)
for point in r.points.values():
point.coordinates = list(np.array(point.coordinates) - offset)

data.save_reconstruction(reconstructions, geocoords_file)

def setup(self, args, images_path, reconstruction, append_config = [], rerun=False):
"""
Setup a OpenSfM project
Expand Down Expand Up @@ -256,7 +300,6 @@ def setup(self, args, images_path, reconstruction, append_config = [], rerun=Fal
"matching_gps_distance: 0",
"matching_graph_rounds: %s" % matcher_graph_rounds,
"optimize_camera_parameters: %s" % ('no' if args.use_fixed_camera_params else 'yes'),
"reconstruction_algorithm: %s" % (args.sfm_algorithm),
"undistorted_image_format: tif",
"bundle_outlier_filtering_type: AUTO",
"sift_peak_threshold: 0.066",
Expand All @@ -272,7 +315,7 @@ def setup(self, args, images_path, reconstruction, append_config = [], rerun=Fal
log.ODM_WARNING("Georeferenced reconstruction, ignoring --matcher-order")

if args.camera_lens != 'auto':
config.append("camera_projection_type: %s" % args.camera_lens.upper())
config.append("default_projection_type: %s" % args.camera_lens)

matcher_type = args.matcher_type
feature_type = args.feature_type.upper()
Expand Down Expand Up @@ -372,7 +415,7 @@ def extract_metadata(self, rerun=False):
if not io.dir_exists(metadata_dir) or rerun:
self.run('extract_metadata')

def photos_to_metadata(self, photos, rolling_shutter, rolling_shutter_readout, rerun=False):
def photos_to_metadata(self, photos, rolling_shutter, rolling_shutter_readout, gps_accuracy, rerun=False):
metadata_dir = self.path("exif")

if io.dir_exists(metadata_dir) and not rerun:
Expand All @@ -388,7 +431,7 @@ def photos_to_metadata(self, photos, rolling_shutter, rolling_shutter_readout, r
data = DataSet(self.opensfm_project_path)

for p in photos:
d = p.to_opensfm_exif(rolling_shutter, rolling_shutter_readout)
d = p.to_opensfm_exif(rolling_shutter, rolling_shutter_readout, gps_accuracy)
with open(os.path.join(metadata_dir, "%s.exif" % p.filename), 'w') as f:
f.write(json.dumps(d, indent=4))

Expand Down Expand Up @@ -488,13 +531,49 @@ def convert_and_undistort(self, rerun=False, imageFilter=None, image_list=None,
if image_list is not None:
ds._set_image_list(image_list)

undistort.run_dataset(ds, "reconstruction.json",
0, None, "undistorted", imageFilter)

# OpenSfM 1.0 removed a callback during image undistortion.
# Wrapper to make current ODM compatible with change.
filter_calls = None
if imageFilter is not None:
filter_calls = self._wrap_load_image_with_filter(ds, imageFilter)

undistort.run_dataset(ds, "reconstruction.json", 0, None, "undistorted")

# TODO: this only works because OpenSfM undistorts with threads, so our wrapped
# load_image is the one the workers call. If it ever moves to separate processes
# the wrapper is bypassed and processing is skipped, so we throw an error if that happens.
if filter_calls is not None and filter_calls[0] == 0:
raise system.ExitException("Undistortion skipped ODM image processing; "
"OpenSfM's undistort backend may have changed.")

self.touch(done_flag_file)
else:
log.ODM_WARNING("Already undistorted (%s)" % runId)

@staticmethod
def _wrap_load_image_with_filter(ds, imageFilter):
"""Run ODM's per-image processing during OpenSfM's undistortion.

OpenSfM loads every image through ds.load_image, so wrapping it runs our
callback (plus an alpha-channel drop undistortion can't handle) on each image.
Returns a one-element list of how many times it ran, so the caller can confirm
it was used.
"""
calls = [0]
original_load_image = ds.load_image

def load_image_with_filter(image, *args, **kwargs):
img = original_load_image(image, *args, **kwargs)
if img is not None:
img = imageFilter(image, img)
if len(img.shape) == 3 and img.shape[2] > 3:
img = img[:, :, :3]
calls[0] += 1
return img

ds.load_image = load_image_with_filter
return calls

def restore_reconstruction_backup(self):
if os.path.exists(self.recon_backup_file()):
# This time export the actual reconstruction.json
Expand Down Expand Up @@ -570,7 +649,10 @@ def export_report(self, report_path, odm_stats, rerun=False):
osfm_report_path = self.path("stats", "report.pdf")
if not os.path.exists(report_path) or rerun:
data = DataSet(self.opensfm_project_path)
pdf_report = report.Report(data, odm_stats)
# FIXME in order to integrate OpenSfM 1.0 we drop the ODM specific
# stats.json --> report data for now.
# This could be added back in later.
pdf_report = report.Report(data)
pdf_report.generate_report()
pdf_report.save_report("report.pdf")

Expand Down Expand Up @@ -619,11 +701,11 @@ def ground_control_points(self, proj4):

ds = DataSet(self.opensfm_project_path)
reference = ds.load_reference()
projection = pyproj.Proj(proj4)
projection = geo.construct_proj_transformer(proj4, inverse=True)

result = []
for gcp in gcps_stats:
geocoords = _transform(gcp['coordinates'], reference, projection)
geocoords = geo.transform_to_proj(gcp['coordinates'], reference, projection)
result.append({
'id': gcp['id'],
'observations': gcp['observations'],
Expand Down
18 changes: 15 additions & 3 deletions opendm/photo.py
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,7 @@ def camera_id(self):
]
).lower()

def to_opensfm_exif(self, rolling_shutter = False, rolling_shutter_readout = 0):
def to_opensfm_exif(self, rolling_shutter = False, rolling_shutter_readout = 0, gps_accuracy = None):
capture_time = 0.0
if self.utc_time is not None:
capture_time = self.utc_time / 1000.0
Expand All @@ -838,10 +838,22 @@ def to_opensfm_exif(self, rolling_shutter = False, rolling_shutter_readout = 0):

dop = self.get_gps_dop()
if dop is None:
dop = 10.0 # Default

dop = gps_accuracy if gps_accuracy is not None else 10.0 # Default

# dop is a single GPS accuracy value. Older OpenSfM code still reads
# it, so keep writing it.
gps['dop'] = dop

# Newer OpenSfM wants horizontal and vertical GPS accuracy separately.
# ODM stores them per axis when it has them (e.g. RTK), so use those.
# If a value is missing, use dop for horizontal and assume vertical is
# 3x less accurate (typical for GPS).
horizontal = self.gps_xy_stddev if self.gps_xy_stddev is not None else dop
vertical = self.gps_z_stddev if self.gps_z_stddev is not None else horizontal * 3.0
gps['latitude_std'] = horizontal
gps['longitude_std'] = horizontal
gps['altitude_std'] = vertical

d = {
"make": self.camera_make,
"model": self.camera_model,
Expand Down
5 changes: 3 additions & 2 deletions opendm/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ class LocalRemoteExecutor:
to use the processing power of the current machine as well as offloading tasks to a
network node.
"""
def __init__(self, nodeUrl, rolling_shutter = False, rerun = False):
def __init__(self, nodeUrl, rolling_shutter = False, sfm_algorithm = 'incremental', rerun = False):
self.node = Node.from_url(nodeUrl)
self.params = {
'tasks': [],
'threads': [],
'rolling_shutter': rolling_shutter,
'sfm_algorithm': sfm_algorithm,
'rerun': rerun
}
self.node_online = True
Expand Down Expand Up @@ -446,7 +447,7 @@ def process_local(self):
log.ODM_INFO("==================================")
octx.feature_matching(self.params['rerun'])
octx.create_tracks(self.params['rerun'])
octx.reconstruct(self.params['rolling_shutter'], True, self.params['rerun'])
octx.reconstruct(self.params['sfm_algorithm'], self.params['rolling_shutter'], True, self.params['rerun'])

def process_remote(self, done):
octx = OSFMContext(self.path("opensfm"))
Expand Down
Loading
Loading