From 575e1526b82e427be9412eb5f72288033af0c9df Mon Sep 17 00:00:00 2001 From: genie360s Date: Wed, 29 Apr 2026 13:38:31 +0300 Subject: [PATCH 1/3] fix: compilation issues for macos silicon chip --- .gitignore | 1 + opensfm/src/bundle/CMakeLists.txt | 3 +- opensfm/src/geometry/CMakeLists.txt | 2 +- opensfm/src/robust/CMakeLists.txt | 3 +- opensfm/src/sfm/CMakeLists.txt | 2 +- opensfm_runner.py | 439 ++++++++++++++++++++++++++++ opensfm_runner.spec | 53 ++++ requirements.txt | 11 + setup.py | 17 +- 9 files changed, 525 insertions(+), 6 deletions(-) create mode 100644 opensfm_runner.py create mode 100644 opensfm_runner.spec create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index ea19cfda8..f5a721f64 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ data/berlin/* env/* PACKAGE +.venv/ diff --git a/opensfm/src/bundle/CMakeLists.txt b/opensfm/src/bundle/CMakeLists.txt index 9aa7a0b9a..1957505b0 100644 --- a/opensfm/src/bundle/CMakeLists.txt +++ b/opensfm/src/bundle/CMakeLists.txt @@ -16,8 +16,9 @@ set(BUNDLE_FILES ) add_library(bundle ${BUNDLE_FILES}) target_link_libraries(bundle - PRIVATE + PUBLIC ${CERES_LIBRARIES} + PRIVATE ${LAPACK_LIBRARIES} ${SUITESPARSE_LIBRARIES} foundation diff --git a/opensfm/src/geometry/CMakeLists.txt b/opensfm/src/geometry/CMakeLists.txt index 8bdc5455a..893d9f1ba 100644 --- a/opensfm/src/geometry/CMakeLists.txt +++ b/opensfm/src/geometry/CMakeLists.txt @@ -20,7 +20,7 @@ set(GEOMETRY_FILES ) add_library(geometry ${GEOMETRY_FILES}) target_link_libraries(geometry - PRIVATE + PUBLIC foundation ${CERES_LIBRARIES} ) diff --git a/opensfm/src/robust/CMakeLists.txt b/opensfm/src/robust/CMakeLists.txt index a9eb0f2f9..63f429ee9 100644 --- a/opensfm/src/robust/CMakeLists.txt +++ b/opensfm/src/robust/CMakeLists.txt @@ -17,9 +17,10 @@ set(ROBUST_FILES ) add_library(robust ${ROBUST_FILES}) target_link_libraries(robust + PUBLIC + geometry PRIVATE foundation - geometry ) target_include_directories(robust PUBLIC ${CMAKE_SOURCE_DIR}) diff --git a/opensfm/src/sfm/CMakeLists.txt b/opensfm/src/sfm/CMakeLists.txt index ffc8d6e56..a13b62ee7 100644 --- a/opensfm/src/sfm/CMakeLists.txt +++ b/opensfm/src/sfm/CMakeLists.txt @@ -12,10 +12,10 @@ add_library(sfm ${SFM_FILES}) target_link_libraries(sfm PUBLIC Eigen3::Eigen + bundle PRIVATE foundation map - bundle vl ) target_include_directories(sfm PUBLIC ${CMAKE_SOURCE_DIR}) diff --git a/opensfm_runner.py b/opensfm_runner.py new file mode 100644 index 000000000..9611ef4c0 --- /dev/null +++ b/opensfm_runner.py @@ -0,0 +1,439 @@ +""" +opensfm_runner — standalone OpenSfM pipeline binary +===================================================== + +SYNOPSIS + opensfm_runner [options] + opensfm_runner -h | --help + +COMMANDS + run Run the full pipeline end-to-end (default) + extract_metadata Extract EXIF metadata and camera models from images + detect_features Detect and describe keypoint features in each image + match_features Match features across overlapping image pairs + create_tracks Link matched features into 3-D tracks + reconstruct Run incremental Structure-from-Motion reconstruction + export_ply Export the reconstruction to a PLY point-cloud file + test Run the built-in test suite against a dataset + +PIPELINE ORDER + extract_metadata → detect_features → match_features → + create_tracks → reconstruct → export_ply + +OUTPUT FILES (all written inside /) + exif/.exif — per-image EXIF metadata (extract_metadata) + camera_models.json — camera model catalogue (extract_metadata) + features/.features.npz— keypoints + descriptors (detect_features) + reference_lla.json — GPS reference point (match_features) + matches/_matches.pkl.gz — pairwise matches (match_features) + tracks.csv — feature tracks (create_tracks) + reconstruction.json — 3-D reconstruction (reconstruct) + reconstruction.ply — point cloud (export_ply) + +EXAMPLES + # Full pipeline + opensfm_runner run data/lund + + # Single step + opensfm_runner extract_metadata data/lund + opensfm_runner detect_features data/lund + opensfm_runner match_features data/lund + opensfm_runner create_tracks data/lund + opensfm_runner reconstruct data/lund + opensfm_runner export_ply data/lund + + # Test suite (cleans existing outputs first by default) + opensfm_runner test data/lund + opensfm_runner test data/lund --no-clean + + # Full pipeline with JSON progress output (for programmatic use) + opensfm_runner run data/lund --json + +NOTES + - Progress is printed as human-readable text by default. + - Use --json to emit newline-delimited JSON for programmatic consumers + (e.g. a Tauri desktop app reading stdout). + - "WARNING: free: command not found" is harmless on macOS; OpenSfM + tries to read available RAM via a Linux-only utility. + - The 'single-line alignment' warning means the dataset images were + captured along a roughly linear path; a horizontal prior is used. +""" + +import sys +import json +import os +import argparse +import multiprocessing +import glob + +# Required for PyInstaller + multiprocessing on macOS +multiprocessing.freeze_support() + +# Fix path for PyInstaller bundle +if getattr(sys, 'frozen', False): + bundle_dir = sys._MEIPASS + sys.path.insert(0, bundle_dir) + +from opensfm.dataset import DataSet +from opensfm.actions import ( + extract_metadata, + detect_features, + match_features, + create_tracks, + reconstruct, + export_ply, +) +from opensfm.reconstruction import ReconstructionAlgorithm + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +def _emit(msg, as_json, **extra): + if as_json: + print(json.dumps({"message": msg, **extra}), flush=True) + else: + print(msg, flush=True) + +def _progress(step, as_json): + if as_json: + print(json.dumps({"status": "progress", "step": step}), flush=True) + else: + print(f"[{step}]", flush=True) + +def _done(dataset_path, as_json): + if as_json: + print(json.dumps({"status": "done", "path": dataset_path}), flush=True) + else: + print(f"Done: {dataset_path}", flush=True) + +def _error(message, as_json): + if as_json: + print(json.dumps({"status": "error", "message": message}), flush=True) + else: + print(f"ERROR: {message}", file=sys.stderr, flush=True) + + +# --------------------------------------------------------------------------- +# Individual step runners +# --------------------------------------------------------------------------- + +def step_extract_metadata(dataset, as_json=False): + _progress("Extracting metadata", as_json) + extract_metadata.run_dataset(dataset) + +def step_detect_features(dataset, as_json=False): + _progress("Detecting features", as_json) + detect_features.run_dataset(dataset) + +def step_match_features(dataset, as_json=False): + _progress("Matching features", as_json) + match_features.run_dataset(dataset) + +def step_create_tracks(dataset, as_json=False): + _progress("Creating tracks", as_json) + create_tracks.run_dataset(dataset) + +def step_reconstruct(dataset, as_json=False): + _progress("Reconstructing", as_json) + reconstruct.run_dataset(dataset, ReconstructionAlgorithm.INCREMENTAL) + +def step_export_ply(dataset, as_json=False): + _progress("Exporting PLY", as_json) + export_ply.run_dataset( + dataset, + no_cameras=False, + no_points=False, + depthmaps=False, + point_num_views=False, + ) + + +STEPS = [ + ("extract_metadata", step_extract_metadata), + ("detect_features", step_detect_features), + ("match_features", step_match_features), + ("create_tracks", step_create_tracks), + ("reconstruct", step_reconstruct), + ("export_ply", step_export_ply), +] + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +def cmd_run(dataset_path, as_json=False): + dataset = DataSet(dataset_path) + for _, fn in STEPS: + fn(dataset, as_json) + _done(dataset_path, as_json) + + +def cmd_single_step(step_name, dataset_path, as_json=False): + dataset = DataSet(dataset_path) + fn = dict(STEPS)[step_name] + fn(dataset, as_json) + _done(dataset_path, as_json) + + +# --------------------------------------------------------------------------- +# Test suite +# --------------------------------------------------------------------------- + +class TestResult: + def __init__(self): + self.passed = [] + self.failed = [] + + def check(self, description, condition, detail=""): + if condition: + self.passed.append(description) + print(f" PASS {description}", flush=True) + else: + self.failed.append(description) + msg = f" FAIL {description}" + if detail: + msg += f"\n → {detail}" + print(msg, flush=True) + + def summary(self): + total = len(self.passed) + len(self.failed) + print(flush=True) + print(f"Results: {len(self.passed)}/{total} passed", flush=True) + if self.failed: + print("Failed:", flush=True) + for f in self.failed: + print(f" - {f}", flush=True) + return len(self.failed) == 0 + + +def cmd_test(dataset_path, clean=True): + if not os.path.isdir(dataset_path): + print(f"ERROR: dataset path does not exist: {dataset_path}", file=sys.stderr) + sys.exit(1) + + images_dir = os.path.join(dataset_path, "images") + if not os.path.isdir(images_dir): + print(f"ERROR: no images/ directory in {dataset_path}", file=sys.stderr) + sys.exit(1) + + images = sorted( + f for f in os.listdir(images_dir) + if f.lower().endswith((".jpg", ".jpeg", ".png", ".tif", ".tiff")) + ) + if not images: + print(f"ERROR: no images found in {images_dir}", file=sys.stderr) + sys.exit(1) + + print(f"Dataset : {dataset_path}", flush=True) + print(f"Images : {len(images)}", flush=True) + + # Optionally clean computed outputs (keep images, config, camera_models) + if clean: + print("Cleaning previous outputs ...", flush=True) + for d in ("exif", "features", "matches", "reports"): + import shutil + target = os.path.join(dataset_path, d) + if os.path.isdir(target): + shutil.rmtree(target) + for f in ("tracks.csv", "reconstruction.json", "reconstruction.ply", + "reference_lla.json"): + p = os.path.join(dataset_path, f) + if os.path.isfile(p): + os.remove(p) + + r = TestResult() + dataset = DataSet(dataset_path) + + # --- extract_metadata --- + print("\n[1/6] extract_metadata", flush=True) + try: + extract_metadata.run_dataset(dataset) + exif_files = glob.glob(os.path.join(dataset_path, "exif", "*.exif")) + r.check("exif/ dir created", os.path.isdir(os.path.join(dataset_path, "exif"))) + r.check( + f"exif file count matches image count ({len(images)})", + len(exif_files) == len(images), + f"found {len(exif_files)} exif files", + ) + r.check( + "camera_models.json created", + os.path.isfile(os.path.join(dataset_path, "camera_models.json")), + ) + except Exception as e: + r.check("extract_metadata ran without exception", False, str(e)) + + # --- detect_features --- + print("\n[2/6] detect_features", flush=True) + try: + detect_features.run_dataset(dataset) + feat_files = glob.glob(os.path.join(dataset_path, "features", "*.features.npz")) + r.check("features/ dir created", os.path.isdir(os.path.join(dataset_path, "features"))) + r.check( + f"feature file count matches image count ({len(images)})", + len(feat_files) == len(images), + f"found {len(feat_files)} feature files", + ) + # Spot-check: first feature file is non-empty + if feat_files: + r.check( + "first feature file is non-empty", + os.path.getsize(feat_files[0]) > 0, + feat_files[0], + ) + except Exception as e: + r.check("detect_features ran without exception", False, str(e)) + + # --- match_features --- + print("\n[3/6] match_features", flush=True) + try: + match_features.run_dataset(dataset) + match_files = glob.glob(os.path.join(dataset_path, "matches", "*_matches.pkl.gz")) + r.check("matches/ dir created", os.path.isdir(os.path.join(dataset_path, "matches"))) + r.check( + "at least one match file produced", + len(match_files) > 0, + f"found {len(match_files)} match files", + ) + r.check( + "reference_lla.json created", + os.path.isfile(os.path.join(dataset_path, "reference_lla.json")), + ) + except Exception as e: + r.check("match_features ran without exception", False, str(e)) + + # --- create_tracks --- + print("\n[4/6] create_tracks", flush=True) + try: + create_tracks.run_dataset(dataset) + tracks_path = os.path.join(dataset_path, "tracks.csv") + r.check("tracks.csv created", os.path.isfile(tracks_path)) + if os.path.isfile(tracks_path): + r.check( + "tracks.csv is non-empty", + os.path.getsize(tracks_path) > 0, + ) + except Exception as e: + r.check("create_tracks ran without exception", False, str(e)) + + # --- reconstruct --- + print("\n[5/6] reconstruct", flush=True) + try: + reconstruct.run_dataset(dataset, ReconstructionAlgorithm.INCREMENTAL) + recon_path = os.path.join(dataset_path, "reconstruction.json") + r.check("reconstruction.json created", os.path.isfile(recon_path)) + if os.path.isfile(recon_path): + with open(recon_path) as f: + data = json.load(f) + r.check( + "reconstruction.json contains at least one reconstruction", + isinstance(data, list) and len(data) > 0, + f"found {len(data) if isinstance(data, list) else '?'} reconstruction(s)", + ) + if isinstance(data, list) and data: + shots = data[0].get("shots", {}) + r.check( + f"reconstruction contains shots ({len(shots)} cameras placed)", + len(shots) > 0, + ) + except Exception as e: + r.check("reconstruct ran without exception", False, str(e)) + + # --- export_ply --- + print("\n[6/6] export_ply", flush=True) + try: + export_ply.run_dataset( + dataset, + no_cameras=False, + no_points=False, + depthmaps=False, + point_num_views=False, + ) + ply_path = os.path.join(dataset_path, "reconstruction.ply") + r.check("reconstruction.ply created", os.path.isfile(ply_path)) + if os.path.isfile(ply_path): + size = os.path.getsize(ply_path) + r.check( + f"reconstruction.ply is non-empty ({size} bytes)", + size > 0, + ) + except Exception as e: + r.check("export_ply ran without exception", False, str(e)) + + ok = r.summary() + sys.exit(0 if ok else 1) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +HELP_TEXT = __doc__ + +def build_parser(): + parser = argparse.ArgumentParser( + prog="opensfm_runner", + description="Standalone OpenSfM pipeline binary", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=HELP_TEXT, + add_help=True, + ) + + sub = parser.add_subparsers(dest="command", metavar="") + + # run (full pipeline) + p_run = sub.add_parser("run", help="Run the full pipeline end-to-end") + p_run.add_argument("dataset", help="Path to the dataset directory") + p_run.add_argument("--json", action="store_true", help="Emit newline-delimited JSON progress") + + # individual steps + for step_name, _ in STEPS: + p = sub.add_parser(step_name, help=f"Run only the {step_name} step") + p.add_argument("dataset", help="Path to the dataset directory") + p.add_argument("--json", action="store_true", help="Emit newline-delimited JSON progress") + + # test + p_test = sub.add_parser("test", help="Run test suite and validate outputs") + p_test.add_argument("dataset", help="Path to the dataset directory") + p_test.add_argument( + "--no-clean", + action="store_true", + help="Do not remove existing outputs before testing", + ) + + return parser + + +def main(): + parser = build_parser() + + # If called with no arguments, print help + if len(sys.argv) == 1: + parser.print_help() + sys.exit(0) + + # Legacy: if first arg looks like a path (not a known command), run full pipeline + known_commands = {"run", "test"} | {name for name, _ in STEPS} + if sys.argv[1] not in known_commands and not sys.argv[1].startswith("-"): + # Treat as: opensfm_runner (original behaviour, JSON output) + dataset_path = sys.argv[1] + cmd_run(dataset_path, as_json=True) + return + + args = parser.parse_args() + + if args.command == "run": + cmd_run(args.dataset, as_json=args.json) + elif args.command == "test": + cmd_test(args.dataset, clean=not args.no_clean) + elif args.command in dict(STEPS): + cmd_single_step(args.command, args.dataset, as_json=args.json) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/opensfm_runner.spec b/opensfm_runner.spec new file mode 100644 index 000000000..cf5269ce8 --- /dev/null +++ b/opensfm_runner.spec @@ -0,0 +1,53 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +datas = [] +binaries = [] +hiddenimports = ['xmltodict', 'opensfm.actions.extract_metadata', 'opensfm.actions.detect_features', 'opensfm.actions.match_features', 'opensfm.actions.create_tracks', 'opensfm.actions.reconstruct', 'opensfm.actions.export_ply'] +tmp_ret = collect_all('opensfm') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('yaml') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('pyproj') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('fpdf') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('scipy') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['opensfm_runner.py'], + pathex=['/Users/alexmkwizu/Documents/SoftwareProjects/OpenSfM'], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='opensfm_runner', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..048c14d14 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +numpy==1.23.5 +scipy==1.10.1 +networkx==3.1 +Pillow==10.0.0 +matplotlib==3.7.2 +pyyaml==6.0.1 +exifread==3.0.0 +flask==2.3.3 +sphinx==5.3.0 +sphinx-rtd-theme==1.2.2 +cython==0.29.36 diff --git a/setup.py b/setup.py index 96620d3b2..1abf9023e 100644 --- a/setup.py +++ b/setup.py @@ -6,9 +6,13 @@ import sys import setuptools -from sphinx.setup_command import BuildDoc from wheel.bdist_wheel import bdist_wheel +try: + from sphinx.setup_command import BuildDoc +except ImportError: + BuildDoc = None + VERSION = (0, 5, 2) @@ -40,6 +44,15 @@ def configure_c_extension(): "-DVCPKG_TARGET_TRIPLET=x64-windows", "-DCMAKE_TOOLCHAIN_FILE=../vcpkg/scripts/buildsystems/vcpkg.cmake", ] + # When building inside a conda/micromamba environment, pass paths explicitly + conda_prefix = os.environ.get("CONDA_PREFIX") + if conda_prefix: + cmake_command += [ + f"-DEigen3_DIR={conda_prefix}/share/eigen3/cmake", + f"-DOpenCV_DIR={conda_prefix}/lib/cmake/opencv4", + f"-DCeres_DIR={conda_prefix}/lib/cmake/Ceres", + f"-DCMAKE_PREFIX_PATH={conda_prefix}", + ] subprocess.check_call(cmake_command, cwd="cmake_build") @@ -95,7 +108,7 @@ def build_c_extension(): }, cmdclass={ "bdist_wheel": platform_bdist_wheel, - "build_doc": BuildDoc, + **( {"build_doc": BuildDoc} if BuildDoc is not None else {} ), }, command_options={ "build_doc": { From 9d20ac53ac72d8fe716760c47a4b5fa9a0d6b9b7 Mon Sep 17 00:00:00 2001 From: genie360s Date: Wed, 29 Apr 2026 16:47:11 +0300 Subject: [PATCH 2/3] update: conda.yml and requirements.txt --- conda.yml | 24 +++++++++++++++++++++--- requirements.txt | 25 ++++++++++++++----------- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/conda.yml b/conda.yml index 1e163ceff..73df7404a 100644 --- a/conda.yml +++ b/conda.yml @@ -1,12 +1,30 @@ name: opensfm dependencies: - python=3.10 - - cmake=3.31 + - cmake>=3.31 - make - libxcrypt - libopencv *=headless* - - py-opencv + - py-opencv>=4.13 - conda-forge::libvorbis - - ceres-solver=2.1 + - ceres-solver>=2.1 - conda-forge::llvm-openmp - conda-forge::cxx-compiler + - eigen>=3.4 + - suitesparse>=5.10 + - numpy>=2.2 + - scipy>=1.15 + - networkx>=3.4 + - pillow>=12.0 + - matplotlib>=3.10 + - pyyaml>=6.0 + - exifread>=3.5 + - flask>=3.1 + - pyproj>=3.7 + - fpdf2>=2.8 + - xmltodict>=1.0 + - joblib>=1.5 + - pip: + - pyinstaller>=6.0 + - sphinx>=8.0 + - sphinx-rtd-theme>=2.0 diff --git a/requirements.txt b/requirements.txt index 048c14d14..6137d1584 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,14 @@ -numpy==1.23.5 -scipy==1.10.1 -networkx==3.1 -Pillow==10.0.0 -matplotlib==3.7.2 -pyyaml==6.0.1 -exifread==3.0.0 -flask==2.3.3 -sphinx==5.3.0 -sphinx-rtd-theme==1.2.2 -cython==0.29.36 +numpy>=2.2 +scipy>=1.15 +networkx>=3.4 +Pillow>=12.0 +matplotlib>=3.10 +pyyaml>=6.0 +exifread>=3.5 +flask>=3.1 +pyproj>=3.7 +fpdf2>=2.8 +xmltodict>=1.0 +joblib>=1.5 +sphinx>=8.0 +sphinx-rtd-theme>=2.0 From 236e6099a3f4d06071f1679b99fe96e3f1e54eaf Mon Sep 17 00:00:00 2001 From: genie360s Date: Wed, 29 Apr 2026 16:49:17 +0300 Subject: [PATCH 3/3] add:stand alone binary instructions --- standalone_binary_instructions/README.md | 198 +++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 standalone_binary_instructions/README.md diff --git a/standalone_binary_instructions/README.md b/standalone_binary_instructions/README.md new file mode 100644 index 000000000..e75476177 --- /dev/null +++ b/standalone_binary_instructions/README.md @@ -0,0 +1,198 @@ +# opensfm_runner + +Standalone OpenSfM pipeline binary for macOS (arm64 / Apple Silicon). +No Python installation or dependencies required. + +--- + +## Requirements + +- macOS on Apple Silicon (arm64) +- The binary is self-contained — no Python, conda, or pip needed + +--- + +## Quick Start + +```bash +# Make executable (first time only) +chmod +x ./opensfm_runner + +# Full help manual +./opensfm_runner --help + +# Run the full pipeline on a dataset +./opensfm_runner run /path/to/dataset +``` + +A **dataset directory** must contain: +- `images/` — JPEG/PNG/TIFF source photos +- `config.yaml` — OpenSfM configuration (optional; defaults are used if absent) + +--- + +## Commands + +### Full pipeline + +Runs all six steps in order and writes all output files to the dataset directory. + +```bash +./opensfm_runner run /path/to/dataset +``` + +Add `--json` for newline-delimited JSON progress (useful for programmatic consumers such as a Tauri app reading stdout): + +```bash +./opensfm_runner run /path/to/dataset --json +``` + +### Individual steps + +Run a single step independently. Steps must be executed in pipeline order. + +```bash +./opensfm_runner extract_metadata /path/to/dataset +./opensfm_runner detect_features /path/to/dataset +./opensfm_runner match_features /path/to/dataset +./opensfm_runner create_tracks /path/to/dataset +./opensfm_runner reconstruct /path/to/dataset +./opensfm_runner export_ply /path/to/dataset +``` + +Each step also accepts `--json` for JSON output. + +### Pipeline order + +``` +extract_metadata → detect_features → match_features → +create_tracks → reconstruct → export_ply +``` + +--- + +## Output Files + +All outputs are written inside the dataset directory: + +| File | Created by | +|---|---| +| `exif/.exif` | extract_metadata | +| `camera_models.json` | extract_metadata | +| `features/.features.npz` | detect_features | +| `reference_lla.json` | match_features | +| `matches/_matches.pkl.gz` | match_features | +| `tracks.csv` | create_tracks | +| `reconstruction.json` | reconstruct | +| `reconstruction.ply` | export_ply | + +--- + +## Running the Test Suite + +The `test` command runs all six pipeline steps from scratch against a dataset and validates that every expected output file is produced correctly. It runs 16 checks in total. + +```bash +# Clean run — removes previous outputs first, then runs all steps +./opensfm_runner test /path/to/dataset + +# Validate existing outputs without re-running (no clean) +./opensfm_runner test /path/to/dataset --no-clean +``` + +### Example output + +``` +Dataset : data/lund +Images : 29 +Cleaning previous outputs ... + +[1/6] extract_metadata + PASS exif/ dir created + PASS exif file count matches image count (29) + PASS camera_models.json created + +[2/6] detect_features + PASS features/ dir created + PASS feature file count matches image count (29) + PASS first feature file is non-empty + +[3/6] match_features + PASS matches/ dir created + PASS at least one match file produced + PASS reference_lla.json created + +[4/6] create_tracks + PASS tracks.csv created + PASS tracks.csv is non-empty + +[5/6] reconstruct + PASS reconstruction.json created + PASS reconstruction.json contains at least one reconstruction + PASS reconstruction contains shots (29 cameras placed) + +[6/6] export_ply + PASS reconstruction.ply created + PASS reconstruction.ply is non-empty (3891148 bytes) + +Results: 16/16 passed +``` + +Exit code is `0` on full pass, `1` if any check fails. + +--- + +## Known Warnings (harmless) + +| Warning | Cause | Impact | +|---|---|---| +| `/bin/sh: free: command not found` | OpenSfM checks available RAM via a Linux-only command | None — ignored on macOS | +| `Shots aligned on a single-line. Using horizontal prior` | Dataset images were captured in a nearly straight line | None — a fallback alignment prior is used automatically | + +--- + +## Troubleshooting + +**`zlib.error: unknown compression method` on startup** + +This happens when macOS reuses a stale extraction cache from a previous version of the binary. Clear it with: + +```bash +find "$TMPDIR" -maxdepth 1 -name '_MEI*' -exec rm -rf {} + +``` + +Then run the binary again. + +**Fewer shots placed than images in the dataset** + +This is normal for datasets with a linear capture path (e.g. a straight walkway). The incremental reconstructor may exclude frames that don't meet geometric constraints. The reconstruction is still valid. + +--- + +## Rebuilding the Binary + +If you modify `opensfm_runner.py`, rebuild with: + +```bash +CENV=/opt/homebrew/Cellar/micromamba/2.5.0_4/envs/opensfm +PYTHONPATH=/path/to/OpenSfM \ +$CENV/bin/pyinstaller \ + --onefile --name opensfm_runner \ + --paths /path/to/OpenSfM \ + --collect-all opensfm --collect-all yaml --collect-all pyproj \ + --collect-all fpdf --collect-all scipy \ + --hidden-import xmltodict \ + --hidden-import opensfm.actions.extract_metadata \ + --hidden-import opensfm.actions.detect_features \ + --hidden-import opensfm.actions.match_features \ + --hidden-import opensfm.actions.create_tracks \ + --hidden-import opensfm.actions.reconstruct \ + --hidden-import opensfm.actions.export_ply \ + opensfm_runner.py +``` + +Then clear the extraction cache before testing the new build: + +```bash +find "$TMPDIR" -maxdepth 1 -name '_MEI*' -exec rm -rf {} + +```