diff --git a/.clang-format b/.clang-format index f6cb8ad93..52090406d 100644 --- a/.clang-format +++ b/.clang-format @@ -1 +1,2 @@ BasedOnStyle: Google +InsertBraces: true diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..97198dbdf --- /dev/null +++ b/.coveragerc @@ -0,0 +1,18 @@ +[run] +data_file = coverage_data/.coverage +branch = True +relative_files = True +source = + opensfm +omit = + opensfm/test/* + +[report] +omit = + opensfm/test/* + +[xml] +output = coverage_data/coverage-python.xml + +[html] +directory = coverage_data/htmlcov \ No newline at end of file diff --git a/.dir-locals.el b/.dir-locals.el new file mode 100644 index 000000000..f12c3c324 --- /dev/null +++ b/.dir-locals.el @@ -0,0 +1,7 @@ +;;; Directory Local Variables -*- no-byte-compile: t; -*- +;;; For more information see (info "(emacs) Directory Variables") + +((c++-mode . ((c-basic-offset . 2) + (tab-width . 2))) + (c++-ts-mode . ((c-ts-mode-indent-offset . 2) + (tab-width . 2)))) diff --git a/.dockerignore b/.dockerignore index 83f5d8131..9d27b7d84 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ # Pick what to include !bin +!configs !data/berlin/config.yaml !data/berlin/gcp_list.txt !data/berlin/images @@ -11,8 +12,10 @@ !doc !LICENSE !opensfm +!pyproject.toml +!conda.yml +!conda-*.lock !README.md -!requirements.txt !setup.cfg !setup.py !viewer diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..7cf6d9fcb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug report +about: Create a bug report so we can improve +title: '' +labels: '' +assignees: YanNoun + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Please give us a reproduction step (command). If there's NO REPRO steps, WE'LL CLOSE this issue immediately. + +**Screenshots or Log** +Please attach some screenshot or log that show what's happening. + +**Platform** +Linux (which distrib' and version) ? +WIndows ? +Mac ? + +**Additional context** +Add any other context about the problem here. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..54b3b0f3b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,80 @@ +# OpenSfM AI Coding Instructions + +## 1. Architecture & "Big Picture" +OpenSfM is a Structure-from-Motion library with a hybrid architecture: +- **Core Logic (C++)**: Heavy computational tasks (geometry, bundle adjustment, features) are implemented in C++ under `opensfm/src/`. These are exposed to Python as compiled modules (e.g., `pygeometry`, `pymap`, `pfm`) using **PyBind11**. +- **Pipeline Layout (Python)**: The application logic, pipeline orchestration, and user interface are in Python (`opensfm/` package). +- **Data Abstraction**: The `DataSet` class (`opensfm/dataset.py`) is the central definition for filesystem interactions. All pipeline stages read/write to a hardcoded directory structure (`images/`, `config.yaml`, `reconstruction.json`) managed by this class. +- **State Management**: The `Reconstruction` class (`opensfm/types.py`) wraps the C++ `pymap.Map` object. This pattern (Python class holding a C++ handle) is pervasive. + +## 2. Key Developer Workflows + +### Building +The project uses `scikit-build-core` to compile C++ extensions. +- **Full Build**: `pip install -e .[test]` (Builds C++ extensions and tests, and installs the package in editable mode). +- **Rebuild C++**: Re-running `pip install -e .` is usually required after changing `.cc` files. +- **Dependencies**: Managed via `pyproject.toml` for managing Python dependencies. Outer envinvironment (C++ toolchain, libraries) is set up through `conda` and the `conda.yml` file. Any change to `conda.yml` requires recreating the conda environment with `conda env create --file conda.yml --yes`, then activating it with `conda activate opensfm`. Note that before running the build commands, the conda environment must be activated once. + +### Testing +- **Framework**: + - Python : `pytest` is used, and tests are in files `test_YYY.py` under `opensfm/test`. + - C++ : `gtest` is used. For a given library XXX, tests are put under `opensfm/src/XXX/test/` as `YYY_test.cc`. +- **Style**: we favor many tests with few asserts and ideally one function to be tested. Only a few (1-2) larger integration tests per file. We also strive to test using toy examples to check for function correctness and not just check return semantics. +- **Location**: `opensfm/test/` for Python tests. C++ tests are in `./cmake_build`. +- **Synthetic Data**: Python tests heavily rely on synthetic scenes generated in `opensfm/test/conftest.py`. Look for fixtures like `scene_synthetic` or `scene_synthetic_cube`. +- **Run Tests**: `pytest opensfm/test/` for Python tests. C++ tests can be run via `ctest` with `cd cmake_build && ctest --output-on-failure && cd ..`. Don't forget to activate the conda environment before running tests, and run `export LD_PRELOAD=$CONDA_PREFIX/lib/libtcmalloc.so` before running. + +### Running the Pipeline +- **Entry Point**: `bin/opensfm` (shell script) -> `bin/opensfm_main.py`. +- **Commands**: Each CLI command (e.g., `detect_features`, `reconstruct`) is implemented as a module in `opensfm/commands/`. +- **Example**: `bin/opensfm reconstruct path/to/dataset` invokes the `run()` method in `opensfm/commands/reconstruct.py`. + +## 3. Coding Conventions & Patterns + +### Python/C++ Interop +- **Wrappers**: Do not use C++ objects directly if a Python wrapper exists (e.g., use `opensfm.types.Reconstruction` instead of `opensfm.pymap.Map` where possible). +- **Extensions**: C++ extensions are imported from the root package (e.g., `from opensfm import pygeometry`). + + +### Code Philosophy +- **Readability**: Prioritize clear, simple and maintainable code. Avoid complicated constructs : you're an seasoned engineer that knows about real-world trade-offs, not a rookie trying to expose its design patterns knowledge. You'll use frameworks and design pattern when they're strictly justified at the present moment, not for an hypothetical future. Use descriptive variable and function names, and break down complex functions into smaller, focused ones. +- **Documentation**: Document all public functions and classes with docstrings. For complex algorithms, include comments explaining the rationale and key steps. +- **Testing**: Write tests for new features and bug fixes. Use the existing test suite as a reference for style and structure. +- **C++ Style**: Follow the existing C++ style in the codebase. Use `clang-format` for consistent formatting. Key points: + * Stick to use STL and Eigen for data structures. Avoid using OpenCV datastructures in C++, except for image resizing. + * Use SoA structuring for performance critical data like point clouds. + * Strive to minimize heap allocation and fragmentation by using stack-allocated buffer. In case of heap-allocated, re-use buffers, and avoid tiny buffers in long for loops. +- **Design**: For the data, try to re-use core lib's data structures. In addition, it is of upmost importance to be able to manipulate large reconstructions efficiently. We strive to handle large amounts of data, so minimize copy operations, use Data-Oriented Design principles, and leverage GPU acceleration where possible (e.g., for map rendering). + +### Type Hinting +- **Strictness**: The codebase uses `pyre-strict`. Ensure all new code has complete type annotations. A comment `# pyre-strict` is often present at the top of files. + +### Configuration +- **Definition**: Default parameters are in `opensfm/config.py` (dataclass `OpenSfMConfig`). +- **Overrides**: Parameters are overridden by `config.yaml` in the dataset directory. +- **Access**: Access config values via `data.config['param_name']` (where `data` is a `DataSet` instance). + +## 4. Essential Files +- `opensfm/dataset.py`: **READ THIS FIRST** when dealing with file I/O. Defines where every file lives. +- `opensfm/types.py`: Defines key data structures (`Reconstruction`, `Shot`, `Camera`). +- `opensfm/config.py`: Documentation for all tunable parameters. +- `opensfm/src/map/map.cc`: The backing C++ implementation for `Reconstruction`. +- `opensfm/commands/`: Implementation of individual pipeline steps. + +## 5. Common Pitfalls +- **Direct File Access**: Avoid manual `open()` calls. Use `dataset.load_*` and `dataset.save_*` methods to ensure consistency with the expected directory structure. +- **Geometry Types**: Be careful with rotation representations (angle-axis vs matrices). `pygeometry` exposes helper functions; check `opensfm/src/geometry/` for implementation details if behavior is unclear. +- **Skipping Conda**: when running or building the library, don't forget to activate the conda environment at least once. + +## 6. General Algorithmic Notes +- **Multi-Platforms**: The codebase is designed to run on Windows, Linux, and macOS. Avoid platform-specific code unless absolutely necessary, and use cross-platform libraries (e.g., Qt for GUI, STL/Eigen for C++). + +Always refer to instructions/memory.instructions.md for project-specific commands, style guides, and recent progress updates. + +If and only if the prompt iS MEMUPDATE, summarize the current state of the project in instructions/memory.instructions.md, including recent progress, lessons learned, and known technical debt. This information should be concise and focused on actionable insights for developers. + +## 7. For coding agents: +- **STRICTLY FORBIDDEN**: + - Do not perform any Git operations (commits, pushes, pulls, merges, rebases, etc.). This is a shared repository and all Git operations must be performed by human developers to ensure proper code review and collaboration. +- **BEST PRACTICES**: + - You will favor using bash tools such as sed or awk to perform complex code replacements, and avoid doing complex string manipulation in Python with ad-hoc scripts that open and write to files directly. This is to ensure that code changes are made in a clear and auditable way for humans, and to leverage the power of existing command-line tools for code transformation. \ No newline at end of file diff --git a/.github/scripts/generate_coverage_badge.py b/.github/scripts/generate_coverage_badge.py new file mode 100644 index 000000000..8bcb4c9b2 --- /dev/null +++ b/.github/scripts/generate_coverage_badge.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Generate a repository-local SVG badge from Cobertura XML reports.""" + +import argparse +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional +from xml.etree import ElementTree +from xml.sax.saxutils import escape + + +@dataclass(frozen=True) +class CoverageTotals: + """Coverage counters extracted from a Cobertura XML report.""" + + name: str + lines_covered: int + lines_valid: int + branches_covered: int + branches_valid: int + + def line_rate(self) -> Optional[float]: + if self.lines_valid == 0: + return None + return (self.lines_covered / self.lines_valid) * 100.0 + + def branch_rate(self) -> Optional[float]: + if self.branches_valid == 0: + return None + return (self.branches_covered / self.branches_valid) * 100.0 + + def to_dict(self) -> Dict[str, object]: + return { + "name": self.name, + "lines_covered": self.lines_covered, + "lines_valid": self.lines_valid, + "line_rate": self.line_rate(), + "branches_covered": self.branches_covered, + "branches_valid": self.branches_valid, + "branch_rate": self.branch_rate(), + } + + +def _parse_count(attributes: Dict[str, str], key: str) -> int: + raw_value = attributes.get(key) + if raw_value is None: + return 0 + return int(float(raw_value)) + + +def _load_report(report_path: Path) -> CoverageTotals: + root = ElementTree.parse(report_path).getroot() + return CoverageTotals( + name=report_path.stem, + lines_covered=_parse_count(root.attrib, "lines-covered"), + lines_valid=_parse_count(root.attrib, "lines-valid"), + branches_covered=_parse_count(root.attrib, "branches-covered"), + branches_valid=_parse_count(root.attrib, "branches-valid"), + ) + + +def _merge_reports(reports: List[CoverageTotals]) -> CoverageTotals: + return CoverageTotals( + name="total", + lines_covered=sum(report.lines_covered for report in reports), + lines_valid=sum(report.lines_valid for report in reports), + branches_covered=sum(report.branches_covered for report in reports), + branches_valid=sum(report.branches_valid for report in reports), + ) + + +def _format_percentage(value: Optional[float]) -> str: + if value is None: + return "n/a" + formatted = f"{value:.1f}".rstrip("0").rstrip(".") + return f"{formatted}%" + + +def _badge_color(rate: Optional[float]) -> str: + if rate is None: + return "#9f9f9f" + if rate < 50.0: + return "#e05d44" + if rate < 70.0: + return "#fe7d37" + if rate < 85.0: + return "#dfb317" + if rate < 95.0: + return "#a4a61d" + return "#4c1" + + +def _segment_width(text: str) -> int: + return max(10, len(text) * 7 + 10) + + +def _build_badge_svg(label: str, value: str, color: str) -> str: + label_width = _segment_width(label) + value_width = _segment_width(value) + total_width = label_width + value_width + label_center = label_width * 5 + value_center = (label_width + (value_width / 2.0)) * 10 + aria_label = escape(f"{label}: {value}") + label_text = escape(label) + value_text = escape(value) + return f"""\n \n \n \n \n \n \n \n \n \n \n \n \n \n {label_text}\n {label_text}\n {value_text}\n {value_text}\n \n\n""" + + +def _build_summary(reports: List[CoverageTotals], total: CoverageTotals) -> Dict[str, object]: + return { + "reports": [report.to_dict() for report in reports], + "total": total.to_dict(), + "badge": { + "label": "coverage", + "message": _format_percentage(total.line_rate()), + "color": _badge_color(total.line_rate()), + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("reports", nargs="+", type=Path, + help="Cobertura XML coverage reports") + parser.add_argument("--output", required=True, type=Path, + help="Path to the SVG badge to write") + parser.add_argument( + "--summary-json", + required=True, + type=Path, + help="Path to a JSON summary file written alongside the badge", + ) + args = parser.parse_args() + + reports = [_load_report(path) for path in args.reports] + total = _merge_reports(reports) + badge_value = _format_percentage(total.line_rate()) + badge_svg = _build_badge_svg( + "coverage", badge_value, _badge_color(total.line_rate())) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.summary_json.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(badge_svg, encoding="utf-8") + args.summary_json.write_text( + json.dumps(_build_summary(reports, total), + indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/conda.yml b/.github/workflows/conda.yml new file mode 100644 index 000000000..cd12653aa --- /dev/null +++ b/.github/workflows/conda.yml @@ -0,0 +1,43 @@ +name: Conda + +on: [push, pull_request] + +jobs: + + build-test: + name: Build conda environment and run tests + + strategy: + matrix: + include: + - os: ubuntu-24.04 + lockfile: conda-linux-64.lock + - os: ubuntu-22.04 + lockfile: conda-linux-64.lock + - os: macos-latest + lockfile: conda-osx-arm64.lock + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Miniconda + uses: conda-incubator/setup-miniconda@v3 + with: + environment-file: ${{ matrix.lockfile }} + activate-environment: opensfm + + - name: Build OpenSfM + shell: bash -l {0} + run: pip install -e .[test] + + - name: Run C++ tests + shell: bash -l {0} + run: cd build && ctest + + - name: Run Python tests + shell: bash -l {0} + run: export LD_PRELOAD=$CONDA_PREFIX/lib/libtcmalloc.so && python -m pytest diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 000000000..eef5d4304 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,100 @@ +name: Coverage + +on: + push: + paths-ignore: + - badges/** + pull_request: + +jobs: + coverage: + name: Build coverage reports + runs-on: ubuntu-24.04 + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Miniconda + uses: conda-incubator/setup-miniconda@v3 + with: + environment-file: conda-linux-64.lock + activate-environment: opensfm + + - name: Build OpenSfM with coverage instrumentation + shell: bash -l {0} + run: | + rm -rf build + pip install \ + --config-settings=cmake.define.OPENSFM_ENABLE_COVERAGE=ON \ + --config-settings=cmake.define.OPENSFM_BUILD_TESTS=ON \ + -e '.[test]' + + - name: Run C++ tests + shell: bash -l {0} + run: cd build && ctest --output-on-failure + + - name: Run Python tests with coverage + shell: bash -l {0} + run: | + if [[ -f "$CONDA_PREFIX/lib/libtcmalloc.so" ]]; then + export LD_PRELOAD="$CONDA_PREFIX/lib/libtcmalloc.so" + fi + python -m pytest \ + opensfm/test \ + --cov=opensfm \ + --cov-config=.coveragerc \ + --cov-report=term-missing \ + --cov-report=xml:coverage-python.xml \ + -m "not slow" + + - name: Generate C++ coverage report + shell: bash -l {0} + run: | + gcovr \ + --root . \ + build \ + --filter 'opensfm/src/lib/' \ + --exclude 'opensfm/src/lib/third_party/' \ + --exclude '.*/test/.*' \ + --exclude 'opensfm/src/lib/testing_main.cc$' \ + --xml-pretty \ + --output coverage-cpp.xml \ + --print-summary + + - name: Generate SVG coverage badge + shell: bash -l {0} + run: | + python .github/scripts/generate_coverage_badge.py \ + coverage-python.xml \ + coverage-cpp.xml \ + --output badges/coverage.svg \ + --summary-json badges/coverage-summary.json + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + with: + name: coverage-artifacts + path: | + coverage-cpp.xml + coverage-python.xml + badges/coverage.svg + badges/coverage-summary.json + + - name: Commit coverage badge + if: github.event_name == 'push' && github.ref_name == github.event.repository.default_branch + shell: bash -l {0} + run: | + if [[ -z "$(git status --porcelain badges/)" ]]; then + echo "Coverage badge is unchanged" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add badges/coverage.svg badges/coverage-summary.json + git commit -m "Update coverage badge [skip ci]" + git push origin HEAD:${GITHUB_REF_NAME} \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 4a35eab12..000000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Docker CI - -on: [push, pull_request] - -jobs: - - build-test: - name: Build docker and run tests - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - with: - submodules: true - - - name: Build the Docker image - run: docker build . --file Dockerfile --tag mapillary/opensfm:$GITHUB_SHA - - - name: Run C++ tests - run: docker run mapillary/opensfm:$GITHUB_SHA /bin/sh -c "cd cmake_build && ctest" - - - name: Run Python tests - run: docker run mapillary/opensfm:$GITHUB_SHA python3 -m pytest diff --git a/.github/workflows/docker_ceres2.yml b/.github/workflows/docker_ceres2.yml deleted file mode 100644 index 54908c9df..000000000 --- a/.github/workflows/docker_ceres2.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Docker CI - -on: [push, pull_request] - -jobs: - - build-test: - name: Build docker installing CeresSolver 2 and run tests - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - with: - submodules: true - - - name: Build the Docker image - run: docker build . --file Dockerfile.ceres2 --tag mapillary/opensfm.ceres2:$GITHUB_SHA - - - name: Run C++ tests - run: docker run mapillary/opensfm.ceres2:$GITHUB_SHA /bin/sh -c "cd cmake_build && ctest" - - - name: Run Python tests - run: docker run mapillary/opensfm.ceres2:$GITHUB_SHA python3 -m pytest diff --git a/.github/workflows/docker_ubuntu20.yml b/.github/workflows/docker_ubuntu20.yml new file mode 100644 index 000000000..0d25ea0e8 --- /dev/null +++ b/.github/workflows/docker_ubuntu20.yml @@ -0,0 +1,23 @@ +name: Docker Ubuntu 20.04 + +on: [push, pull_request] + +jobs: + + build-test: + name: Build docker and run tests on Ubuntu 20.04 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + with: + submodules: true + + - name: Build the Docker image + run: docker build . --file Dockerfile.ubuntu20 --tag mapillary/opensfm.ubuntu20:$GITHUB_SHA + + - name: Run C++ tests + run: docker run mapillary/opensfm.ubuntu20:$GITHUB_SHA /bin/bash -c 'source ~/.bashrc && conda activate opensfm && cd build && ctest' + + - name: Run Python tests + run: docker run mapillary/opensfm.ubuntu20:$GITHUB_SHA /bin/bash -c 'source ~/.bashrc && conda activate opensfm && export LD_PRELOAD=$CONDA_PREFIX/lib/libtcmalloc.so && python3 -m pytest' \ No newline at end of file diff --git a/.github/workflows/docker_ubuntu24.yml b/.github/workflows/docker_ubuntu24.yml new file mode 100644 index 000000000..603c3e4ff --- /dev/null +++ b/.github/workflows/docker_ubuntu24.yml @@ -0,0 +1,23 @@ +name: Docker Ubuntu 24.04 + +on: [push, pull_request] + +jobs: + + build-test: + name: Build docker and run tests on Ubuntu 24.04 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + with: + submodules: true + + - name: Build the Docker image + run: docker build . --file Dockerfile.ubuntu24 --tag mapillary/opensfm.ubuntu24:$GITHUB_SHA + + - name: Run C++ tests + run: docker run mapillary/opensfm.ubuntu24:$GITHUB_SHA /bin/bash -c 'source ~/.bashrc && conda activate opensfm && cd build && ctest --output-on-failure' + + - name: Run Python tests + run: docker run mapillary/opensfm.ubuntu24:$GITHUB_SHA /bin/bash -c 'source ~/.bashrc && conda activate opensfm && export LD_PRELOAD=$CONDA_PREFIX/lib/libtcmalloc.so && python -m pytest -v' \ No newline at end of file diff --git a/.gitignore b/.gitignore index a739a68fd..4aecd7ade 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,27 @@ *.pyc *.so +*.pyd *.prof xcode .DS_Store launch.json .vscode .idea +.claude +uv.lock # Ignore generated files /build /cmake_build +/coverage_data /doc/build /dist /OpenSfM.egg-info .cache .pytest_cache +/.benchmark-worktrees eval +/benchmark_runs # Viewer node_modules @@ -29,3 +35,5 @@ data/berlin/* # Ignore virtualenv files env/* + +PACKAGE diff --git a/.gitmodules b/.gitmodules index 219b530e7..633f1ca47 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "opensfm/src/third_party/pybind11"] - path = opensfm/src/third_party/pybind11 - url = https://github.com/pybind/pybind11.git +[submodule "opensfm/src/lib/third_party/pybind11"] + path = opensfm/src/lib/third_party/pybind11 + url = https://github.com/pybind/pybind11 diff --git a/CHANGELOG.md b/CHANGELOG.md index 07337507b..761da3d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,24 @@ # Changelog -## [Unreleased] +## 0.8 + +### Added + - Added benchmarking module for qualitative assessment over dataset repositories + - Updated camera database : fc300s, fc300x, m3m, m3e, fc3170, fc3411, fc7203, fc7303, fc8482, sensefly aeria x, sensefly s.o.d.a. + - Quality report generation + - Native rig support + - rerun export + - Support for EXIF OPK angles (DJI and sensefly) + - Support for EXIF per-image Lat/lon/Alt (DJI and sensefly) accuracy + +### Improved + - Much faster processing of large datasets + - Much better memory scaling and handling + - Better accuracy in general + - Improved robustness of weak overlap datasets + - Better handling of missing GPS images + - SfM point cloud cleaning ### Breaking - Main datastructures moved to C++ with Python bindings @@ -9,9 +26,6 @@ - Undistorted image file names only append the image format if it does not match the distorted image source - Undistorted shot ids now match the undistorted image file names and may not match the source shot ids -### Added - - The file `undistorted/undistorted_shot_ids.json` stores a map from the original shot ids to their corresponding list of undistorted shot ids. - ## 0.5.1 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..91468d5ac --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +This file is adapted from the hand-crafted [.github/copilot-instructions.md](.github/copilot-instructions.md), which has proven to work well for coding agents on this repo. Keep the two in sync when changing project-wide guidance. + +## 1. Architecture & "Big Picture" +OpenSfM is a Structure-from-Motion library with a hybrid architecture: +- **Core Logic (C++)**: Heavy computational tasks (geometry, bundle adjustment, features, dense, map) live in C++ under `opensfm/src/lib/`. Each library (e.g., `geometry`, `bundle`, `sfm`, `map`) is laid out as `src/` (implementation), `test/` (gtest `*_test.cc`), and `python/` (PyBind11 bindings). They are exposed to Python as compiled modules (e.g., `pygeometry`, `pybundle`, `pymap`) and imported from the root package (e.g., `from opensfm import pygeometry`). +- **Pipeline Layout (Python)**: Application logic, pipeline orchestration, and the CLI are in the Python `opensfm/` package. +- **Two-layer commands**: `opensfm/commands/.py` are thin CLI wrappers (subclass `CommandBase`, define `name`/`help`, parse args) that delegate to `opensfm/actions/.py`, where the real `run_dataset(data, ...)` logic lives. Add new pipeline steps in both places. +- **Data Abstraction**: The `DataSet` class (`opensfm/dataset.py`) is the central definition for filesystem interactions. All pipeline stages read/write to a hardcoded directory structure (`images/`, `config.yaml`, `reconstruction.json`) managed by this class. +- **State Management**: The `Reconstruction` class (`opensfm/types.py`) wraps the C++ `pymap.Map` object. This pattern (Python class holding a C++ handle) is pervasive. + +## 2. Key Developer Workflows + +> Always activate the conda environment at least once before building, running, or testing: `conda activate opensfm`. + +### Building +The project uses `scikit-build-core` to compile C++ extensions (config in `pyproject.toml`). +- **Full Build**: `pip install -e .[test]` — builds C++ extensions plus tests and installs the package in editable mode. The build directory is `build/` (compiled `.so` files live there via editable redirect mode). +- **Rebuild C++**: Re-run `pip install -e .` after changing `.cc` files. +- **Dependencies**: Python deps are in `pyproject.toml`. The outer environment (C++ toolchain, libraries) is set up through `conda` and `conda.yml`. Any change to `conda.yml` requires recreating the env: `conda env create --file conda.yml --yes` then `conda activate opensfm`. + +### Testing +- **Frameworks**: + - Python: `pytest`, tests in `opensfm/test/test_*.py`. + - C++: `gtest`, tests in `opensfm/src/lib//test/*_test.cc`. +- **Run Python tests**: `pytest opensfm/test/`. First run `export LD_PRELOAD=$CONDA_PREFIX/lib/libtcmalloc.so` (tcmalloc is mandatory). Run a single test with `pytest opensfm/test/test_foo.py::test_bar`. `not slow` excludes long integration tests. +- **Run C++ tests**: built with `OPENSFM_BUILD_TESTS=ON`; run via `ctest --output-on-failure` from the CMake build directory. +- **Coverage**: `./run_coverage.bash` (handles conda activation; defaults to `not slow`). +- **Style**: favor many small tests with few asserts, ideally one function per test; only 1-2 larger integration tests per file. Prefer toy/synthetic examples that verify correctness, not just return semantics. +- **Synthetic Data**: Python tests rely heavily on synthetic scenes generated in `opensfm/test/conftest.py` (e.g., fixtures `scene_synthetic`, `scene_synthetic_cube`). + +### Running the Pipeline +- **Entry Point**: `bin/opensfm` (bash, sets `LD_PRELOAD` for tcmalloc) → `bin/opensfm_main.py` → `commands.command_runner`. +- **Example**: `bin/opensfm reconstruct path/to/dataset` invokes `Command.run_impl` in `opensfm/commands/reconstruct.py`, which calls `actions/reconstruct.py::run_dataset`. + +## 3. Coding Conventions & Patterns + +### Python/C++ Interop +- **Wrappers**: Prefer Python wrappers over raw C++ objects (e.g., use `opensfm.types.Reconstruction` instead of `opensfm.pymap.Map` where possible). + +### Code Philosophy +- **Readability**: Prioritize clear, simple, maintainable code. You're a seasoned engineer who knows real-world trade-offs — apply frameworks/design patterns only when strictly justified now, not for hypothetical futures. Use descriptive names; break complex functions into smaller, focused ones. +- **Documentation**: Docstrings for all public functions/classes. For complex algorithms, comment the rationale and key steps. +- **C++ Style**: Follow existing style; format with `clang-format` (`.clang-format` at repo root). Key points: + * Stick to STL and Eigen for data structures. Avoid OpenCV datastructures in C++, except for image resizing. + * Use SoA (Structure of Arrays) for performance-critical data like point clouds. + * Minimize heap allocation/fragmentation: prefer stack-allocated buffers; reuse heap buffers; avoid tiny allocations inside long loops. +- **Design**: Reuse the core lib's data structures. Manipulating large reconstructions efficiently is paramount — minimize copies, apply Data-Oriented Design, and leverage GPU acceleration where possible. + +### Type Hinting +- Codebase uses `pyre-strict`. New code needs complete type annotations; files often carry a `# pyre-strict` header. + +### Configuration +- **Definition**: Default parameters are in `opensfm/config.py` (dataclass `OpenSfMConfig`) — also the reference doc for tunable parameters. +- **Overrides**: Overridden by `config.yaml` in the dataset directory. +- **Access**: `data.config['param_name']` where `data` is a `DataSet`. + +## 4. Essential Files +- `opensfm/dataset.py`: **READ THIS FIRST** for file I/O — defines where every file lives. +- `opensfm/types.py`: Key data structures (`Reconstruction`, `Shot`, `Camera`). +- `opensfm/config.py`: All tunable parameters. +- `opensfm/src/lib/map/map.cc`: C++ backing implementation for `Reconstruction`. +- `opensfm/commands/` + `opensfm/actions/`: CLI wrappers and their implementations. + +## 5. Common Pitfalls +- **Direct File Access**: Avoid manual `open()`. Use `dataset.load_*` / `dataset.save_*` to stay consistent with the expected directory structure. +- **Geometry Types**: Be careful with rotation representations (angle-axis vs matrices). Check `pygeometry` helpers / `opensfm/src/lib/geometry/` when behavior is unclear. +- **Skipping Conda**: Activate the conda environment at least once before building/running. +- **tcmalloc**: Required at runtime — set `LD_PRELOAD=$CONDA_PREFIX/lib/libtcmalloc.so` (the `bin/opensfm` wrapper does this for you). + +## 6. General Notes +- **Multi-Platform**: Runs on Windows, Linux, macOS. Avoid platform-specific code; use cross-platform libraries (Qt for GUI, STL/Eigen for C++). +- **Code edits**: Favor `sed`/`awk` for complex code replacements over ad-hoc Python scripts that open/write files directly, so changes stay clear and auditable. + +## 7. Policy for Coding Agents +- **No Git operations**: This is a shared repository. Do **not** perform Git operations (commit, push, pull, merge, rebase, etc.) — leave them to human developers so proper code review and collaboration are preserved. Only do so if the user explicitly asks in the current session. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 4bd525a54..0ddf19a02 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -54,13 +54,6 @@ a project may be further defined and clarified by project maintainers. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at . All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbdc0a556..960f76d35 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,27 +5,16 @@ possible. ## Pull Requests We actively welcome your pull requests. -1. Fork the repo and create your branch from `main`. +1. Fork the repo and create your branch from `master`. 2. If you've added code that should be tested, add tests. 3. If you've changed APIs, update the documentation. 4. Ensure the test suite passes. 5. Make sure your code lints. -6. If you haven't already, complete the Contributor License Agreement ("CLA"). - -## Contributor License Agreement ("CLA") -In order to accept your pull request, we need you to submit a CLA. You only need -to do this once to work on any of Facebook's open source projects. - -Complete your CLA here: ## Issues We use GitHub issues to track public bugs. Please ensure your description is clear and has sufficient instructions to be able to reproduce the issue. -Facebook has a [bounty program](https://www.facebook.com/whitehat/) for the safe -disclosure of security bugs. In those cases, please go through the process -outlined on that page and do not file a public issue. - ## License By contributing to OpenSfM, you agree that your contributions will be licensed under the LICENSE file in the root directory of this source tree. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 75de8b3cf..000000000 --- a/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -FROM ubuntu:20.04 - -ARG DEBIAN_FRONTEND=noninteractive - -# Install apt-getable dependencies -RUN apt-get update \ - && apt-get install -y \ - build-essential \ - cmake \ - git \ - libeigen3-dev \ - libopencv-dev \ - libceres-dev \ - python3-dev \ - python3-numpy \ - python3-opencv \ - python3-pip \ - python3-pyproj \ - python3-scipy \ - python3-yaml \ - curl \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - - -COPY . /source/OpenSfM - -WORKDIR /source/OpenSfM - -RUN pip3 install -r requirements.txt && \ - python3 setup.py build diff --git a/Dockerfile.ceres2 b/Dockerfile.ceres2 deleted file mode 100644 index f839a7cc5..000000000 --- a/Dockerfile.ceres2 +++ /dev/null @@ -1,43 +0,0 @@ -FROM ubuntu:20.04 - -ARG DEBIAN_FRONTEND=noninteractive - -# Install apt-getable dependencies -RUN apt-get update \ - && apt-get install -y \ - build-essential \ - cmake \ - git \ - libeigen3-dev \ - libgoogle-glog-dev \ - libopencv-dev \ - libsuitesparse-dev \ - python3-dev \ - python3-numpy \ - python3-opencv \ - python3-pip \ - python3-pyproj \ - python3-scipy \ - python3-yaml \ - curl \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - - -# Install Ceres 2 -RUN \ - mkdir -p /source && cd /source && \ - curl -L http://ceres-solver.org/ceres-solver-2.0.0.tar.gz | tar xz && \ - cd /source/ceres-solver-2.0.0 && \ - mkdir -p build && cd build && \ - cmake .. -DCMAKE_C_FLAGS=-fPIC -DCMAKE_CXX_FLAGS=-fPIC -DBUILD_EXAMPLES=OFF -DBUILD_TESTING=OFF && \ - make -j4 install && \ - cd / && rm -rf /source/ceres-solver-2.0.0 - - -COPY . /source/OpenSfM - -WORKDIR /source/OpenSfM - -RUN pip3 install -r requirements.txt && \ - python3 setup.py build diff --git a/Dockerfile.ubuntu20 b/Dockerfile.ubuntu20 new file mode 100644 index 000000000..f0a6f57eb --- /dev/null +++ b/Dockerfile.ubuntu20 @@ -0,0 +1,36 @@ +FROM ubuntu:20.04 + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y \ + build-essential \ + cmake \ + git \ + wget \ + python3-dev \ + python3-pip \ + python3-venv \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Install Miniconda +FROM continuumio/miniconda3 + +# Setup source +COPY . /source/OpenSfM +WORKDIR /source/OpenSfM + +# Create conda environment from conda lock file (same as native Linux setup) +RUN conda create --name opensfm --file conda-linux-64.lock --yes + +# Override default shell and use bash in the conda environment +SHELL ["conda", "run", "-n", "opensfm", "/bin/bash", "-c"] + +# Build and install OpenSfM using pip with pyproject.toml +# C++ tests are built automatically (OPENSFM_BUILD_TESTS=ON in pyproject.toml) +# and will be available in cmake_build/ directory for running with ctest +RUN pip install --no-cache-dir -e .[test] + +# Ensure we always activate the conda environment when starting a bash shell +RUN echo "conda activate opensfm" >> ~/.bashrc \ No newline at end of file diff --git a/Dockerfile.ubuntu24 b/Dockerfile.ubuntu24 new file mode 100644 index 000000000..64508aed7 --- /dev/null +++ b/Dockerfile.ubuntu24 @@ -0,0 +1,36 @@ +FROM ubuntu:24.04 + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y \ + build-essential \ + cmake \ + git \ + wget \ + python3-dev \ + python3-pip \ + python3-venv \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Install Miniconda +FROM continuumio/miniconda3 + +# Setup source +COPY . /source/OpenSfM +WORKDIR /source/OpenSfM + +# Create conda environment from conda lock file (same as native Linux setup) +RUN conda create --name opensfm --file conda-linux-64.lock --yes + +# Override default shell and use bash in the conda environment +SHELL ["conda", "run", "-n", "opensfm", "/bin/bash", "-c"] + +# Build and install OpenSfM using pip with pyproject.toml +# C++ tests are built automatically (OPENSFM_BUILD_TESTS=ON in pyproject.toml) +# and will be available in cmake_build/ directory for running with ctest +RUN pip install --no-cache-dir -e .[test] + +# Ensure we always activate the conda environment when starting a bash shell +RUN echo "conda activate opensfm" >> ~/.bashrc diff --git a/README.md b/README.md index bf919c0eb..769d8a698 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,142 @@ -## OpenSfM +![OpenSfM](doc/images/logo_small.png) -> Meta has announced that they are no longer actively developing [OpenSfM](https://github.com/mapillary/opensfm). We recommend you use the new repository at https://github.com/OpenSfM/OpenSfM. -This repository contains additional features and fixes that over the years have contributed to serve well [ODX](https://github.com/WebODM/ODX). We're looking to contribute these changes to the new repository. +OpenSfM +======= +[![Conda](https://github.com/OpenSfM/OpenSfM/actions/workflows/conda.yml/badge.svg)](https://github.com/OpenSfM/OpenSfM/actions/workflows/conda.yml) [![Docker Ubuntu 20.04](https://github.com/OpenSfM/OpenSfM/actions/workflows/docker_ubuntu20.yml/badge.svg)](https://github.com/OpenSfM/OpenSfM/actions/workflows/docker_ubuntu20.yml) [![Docker Ubuntu 24.04](https://github.com/OpenSfM/OpenSfM/actions/workflows/docker_ubuntu24.yml/badge.svg)](https://github.com/OpenSfM/OpenSfM/actions/workflows/docker_ubuntu24.yml) + +[![Coverage](./badges/coverage.svg)](https://github.com/OpenSfM/OpenSfM/actions/workflows/coverage.yml) -![Docker workflow](https://github.com/WebODM/opensfm/workflows/Docker%20CI/badge.svg) +[![Discord](https://dcbadge.limes.pink/api/server/https://discord.gg/KrtV85kvgR?style=flat&theme=discord-inverted)](https://discord.gg/KrtV85kvgR) -## Overview -OpenSfM is a Structure from Motion library written in Python. The library serves as a processing pipeline for reconstructing camera poses and 3D scenes from multiple images. It consists of basic modules for Structure from Motion (feature detection/matching, minimal solvers) with a focus on building a robust and scalable reconstruction pipeline. It also integrates external sensor (e.g. GPS, accelerometer) measurements for geographical alignment and robustness. A JavaScript viewer is provided to preview the models and debug the pipeline. +## 🧟 Intro +This repository continues the original [OpenSfM](https://github.com/mapillary/opensfm) project, which is no longer in active development. We were maintainers and contributors of the original OpenSfM, and we will do our best to keep it alive and serve the community and our users ([OpenDroneMap](https://www.opendronemap.org/), [WebODM](https://webodm.org/) and many others). -

- -

+This **1.0** release focuses on the needs of those two biggest users — OpenDroneMap and WebODM — hence the strong emphasis on **GIS / geo workflows**. See the [release notes](RELEASE.md) for the full feature list. -Checkout this [blog post with more demos](http://blog.mapillary.com/update/2014/12/15/sfm-preview.html) +## 🔭 Overview +OpenSfM is an open-source Structure-from-Motion (SfM) library written in Python with performance-critical code in C++. It reconstructs camera poses and sparse 3D points from unordered image collections, and goes all the way to **dense point clouds, meshes, and georeferenced 2D maps (DSM, orthophoto)** — with GPU acceleration throughout. +**🧩 Core pipeline** -## Getting Started +Feature detection (SIFT, HAHOG, DSP-SIFT, AKAZE, SURF, ORB), GPU (OpenCL) matching — both online-trained binary-quantized descriptors and classic FLANN — with geometric verification, track building, and incremental + direct aerotriangulation reconstruction. Robust [Ceres](http://ceres-solver.org/)-based bundle adjustment, switching to a stochastic solver for very large scenes. Pair selection by GPS, capture time, file order, or image similarity (BoW / VLAD). See [pipeline commands](doc/using.md) and the [configuration reference](doc/configuration.md). -* [Building the library][] -* [Running a reconstruction][] -* [Documentation][] +![SfM Reconstruction](doc/images/viewer.png) +**📐 Camera models** -[Building the library]: https://opensfm.org/docs/building.html (OpenSfM building instructions) -[Running a reconstruction]: https://opensfm.org/docs/using.html (OpenSfM usage) -[Documentation]: https://opensfm.org/docs/ (OpenSfM documentation) +Perspective, Brown, fisheye (OpenCV model and custom 62 / 624 parameters), spherical / equirectangular, and dual — with rolling-shutter correction. Lab-calibrated intrinsics can be injected and frozen, and multi-camera rigs are fully supported and can be auto-calibrated. See [camera models](doc/geometry.md) and [rig models](doc/rig.md). -## License +**🧭 Geolocation & georeferencing** -The original OpenSfM code is BSD-style licensed. +GPS positions (with per-image X/Y/Z standard deviation) from EXIF or imported from a text file; ground control points and checkpoints (with per-point standard deviation) in any CRS. Horizontal + vertical coordinate systems via EPSG codes, compound EPSG, or PROJ strings, with geoids fetched on demand from the PROJ CDN, and adaptive datum-shift compensation. See [georeferencing & GIS outputs](doc/georeferencing.md) and [ground control points](doc/ground_control_points.md). -The current code in this repository is AGPLv3 licensed, as found in the LICENSE file. +**🍇 Dense reconstruction** + +Multi-view depth estimation via GPU PatchMatch (OpenCL), sparse-voxel-octree TSDF fusion with optional photometric refinement, and a Surface Nets (dual-contouring) mesh. Exports the dense cloud as PLY / LAS / LAZ, the mesh as PLY, and Potree-style octree tiles for streaming web viewers. See [dense reconstruction & 2D maps](doc/dense.md). + +![Dense Reconstruction](doc/images/dense.png) + +**🧇 2D maps — DSM & orthophoto** + +Direct, TSDF-based Digital Surface Model and orthophoto rendering, with hole filling, an edge-sharpening shock filter, and robust multi-view color baking. Accurately georeferenced to the output CRS (3rd-degree polynomial fit, TPS fallback) and exported as GeoTIFF. See [2D maps](doc/dense.md#2d-maps-dsm-and-orthophoto). + +![DSM and Ortho Extraction](doc/images/dsm_ortho.png) + +**🪜 Scalability** + +Out-of-core submodel splitting / merging for large scenes, rig constraints for multi-camera setups, stochastic bundle adjustment, and configurable multi-processing. See [large datasets](doc/large_datasets.md). + +**📦 Exports** + +COLMAP, Bundler, OpenMVS, PMVS, VisualSFM, PLY, LAS/LAZ, GeoJSON, and GeoTIFF — see [the exporters](doc/using.md#other-exporters). + +**🩺 Quality report** + +SfM metrics, GPS/GCP and checkpoint error tables, and DSM/ortho previews, localized in metric or imperial units and in five languages (en/fr/es/de/it), exported as a PDF. See [quality report](doc/quality_report.md) and an [example report](doc/images/report.pdf). + +**🥽 Visualisation** + +A built-in JavaScript viewer for interactive 3D preview and pipeline debugging, a web point-cloud viewer fed by the Potree octree tiles, and a [Rerun](https://rerun.io/) export of the scene with its GPS/GCP data. + +![Rerun Export](doc/images/rerun.png) + +**🤝 Compatibility** — +Runs on Linux, macOS (Apple Silicon), and Windows. See the [quickstart](doc/quickstart.md) to get started. + +**🫶 Credits** — +OpenSfM was created by Pau Gargallo and bootstrapped by Mapillarians — check out this [blog post with more demos](http://blog.mapillary.com/update/2014/12/15/sfm-preview.html). + + +## 🛫 Getting Started + +Install using conda lock files (see [building instructions](doc/building.md)): + +**Linux:** +```bash +conda create --name opensfm --file conda-linux-64.lock --yes +conda activate opensfm && pip install -e . +``` + +**macOS (Apple Silicon):** +```bash +conda create --name opensfm --file conda-osx-arm64.lock --yes +conda activate opensfm && pip install -e . +``` + +Then reconstruct a dataset: +```bash +conda activate opensfm +./bin/opensfm_run_all path/to/dataset # Linux/macOS +bin\opensfm_run_all.bat path\to\dataset # Windows +``` + +**Workflow presets** — ready-made `config.yaml` files tuned for common capture types live in [`configs/`](configs/) (`aerial`, `terrestrial`, `object`). Copy one into your dataset to start from sensible defaults: `cp configs/aerial.yaml path/to/dataset/config.yaml`. See [workflow presets](doc/using.md#workflow-presets-configs). + +## ⏱️ Benchmarking + +A built-in harness measures the impact of a change on **speed and quality across commits**. It builds any commit in an isolated git worktree + conda env, runs the pipeline on your datasets, and produces an HTML report that diffs the run against a reference commit (green = better, red = worse). + +```bash +# Baseline, then your branch compared against it +python -m benchmark.run --config benchmark/benchmark_example.json --commit master +python -m benchmark.run --config benchmark/benchmark_example.json --commit my-feature --reference master +``` + +Add `--dense` to include the dense stages, or `--resume` to recover an interrupted run. See the [benchmarking guide](doc/benchmark.md) for the full workflow — resuming, report-only regeneration, and partial re-runs. + +## 📚 Documentation + +**Getting Started** +* [Quickstart](doc/quickstart.md) +* [Building & Installation](doc/building.md) +* [Pipeline Commands](doc/using.md) + +**User Guide** +* [Dataset structure](doc/dataset.md) +* [Configuration reference](doc/configuration.md) +* [Ground control points](doc/ground_control_points.md) +* [Rig models](doc/rig.md) +* [Large datasets](doc/large_datasets.md) +* [Dense reconstruction & 2D maps](doc/dense.md) +* [Georeferencing & GIS outputs](doc/georeferencing.md) +* [Quality report](doc/quality_report.md) +* [Troubleshooting](doc/troubleshooting.md) + +**Reference** +* [Camera models & coordinate systems](doc/geometry.md) +* [Reconstruction algorithm](doc/reconstruction.md) +* [Sensor / calibration database](doc/sensor_database.md) +* [Reporting](doc/reporting.md) + +**Mathematical Notes** +* [Dense matching](doc/dense_matching.md) +* [Reconstruction merging](doc/merging_notes.md) + +**Development** +* [Benchmarking](doc/benchmark.md) + +## ⚖️ License +OpenSfM is BSD-style licensed, as found in the LICENSE file. + +Example data in the README is under [Creative Commons CC-BY 4.0 License](https://creativecommons.org/licenses/by/4.0/) by Wingtra AG, 8045 Zürich, Switzerland. \ No newline at end of file diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 000000000..18ae8f412 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,116 @@ +# 🛰️ RELEASE LOG + +## 1.0 + +## 🧟 Why Now ? +It's been a long way since OpenSfM was created by Pau Gargallo in 2013 and Mapillary. Since then, OpenSfM has been internally used as the pipeline that powers +Mapillary's planet-scale 3D reconstruction, and has been used as the core SfM of OpenDroneMap and WebODM since 2016. + +Through these many years, development had its high and lows, mainly focused on supporting Mapillary platform. While at Meta, development continued, but support has been stalled in the past few years, until it was announced as no longer in active development. + +We loved this project and did not want it to die, so we rolled out sleeves and went back to work, eliminating major bottlenecks (speed), while adding new features. + +This 1.0 release is aimed at supporting our two biggest users : OpenDroneMap (ODM/ODX) and WebODM/ODX, hence why there's a focus on GIS/Geo workflows. + +This release is a big jump and the first major so here is the list of features : + +## 🪄 New Features + +> 📚 Full documentation lives in [`doc/`](doc/) — new users should start with the [quickstart](doc/quickstart.md), then the [pipeline command reference](doc/using.md) and the [configuration reference](doc/configuration.md) (the doc mirror of every option in [`opensfm/config.py`](opensfm/config.py)). + +> 🧰 Ready-made `config.yaml` presets tuned for common capture types ship under [`configs/`](configs/) — `aerial`, `terrestrial` and `object`. Copy one into your dataset to start from sensible defaults; see [workflow presets](doc/using.md#workflow-presets-configs). + +### 🧭 Geolocation + +> Guide: [Georeferencing & GIS outputs](doc/georeferencing.md) + +- GPS positions with X/Y/Z standard deviation (per image) — read from EXIF or imported with [`extract_geolocation`](doc/using.md#extract_geolocation) (config: [`bundle_use_gps`, `bundle_compensate_gps_bias`](doc/configuration.md#gps--gcp-alignment)) +- GCP/Checkpoints with X/Y/Z standard deviation (per point) — see [Ground control points](doc/ground_control_points.md) (config: [`bundle_use_gcp`, `gcp_horizontal_sd`, `gcp_vertical_sd`](doc/configuration.md#bundle-adjustment)) +- Horizontal+Vertical coordinate system (EPSG codes, PROJ string) — compound EPSG (e.g. `EPSG:4979+5773`) and raw `+proj=…` strings; see [the output coordinate system](doc/georeferencing.md#the-output-coordinate-system) +- Geoids support through direct download to PROJ CDN (config: [`proj_cdn_enabled`, `proj_grid_cache_dir`](doc/configuration.md#metadata)) +- TXT geolocation file parsing for generating geolocation input (`exif_overrides.json`) from a text file — [`extract_geolocation --geotag-file`](doc/using.md#extract_geolocation) + +### 🧩 SfM + +![SfM Reconstruction](doc/images/viewer.png) + +> Reference: [Pipeline commands](doc/using.md) · [Configuration reference](doc/configuration.md) + +- Features : HAHOG, AKAZE, SIFT, DSP-SIFT, SURF (and ORB) — config: [`feature_type`](doc/configuration.md#features) +- Descriptors : super-fast online-trained (and pre-trained) binary quantized matching with GPU (CUDA) matching. Classic OpenCV FLANN matching. — config: [`matcher_type` (`GPU_HAMMING` / `FLANN`), `binary_training_pairs`](doc/configuration.md#matching) +- Matching : EXIF-based (GPS), image-based (BoW/VLAD) or hybrid (both) matching. — config: [Pair selection](doc/configuration.md#pair-selection) (`matching_gps_distance`, `matching_bow_neighbors`, `matching_vlad_neighbors`) +- SfM : fast incremental and direct aerotriangulation support. — [`reconstruct`](doc/using.md#reconstruct); config: [Incremental reconstruction](doc/configuration.md#incremental-reconstruction). +- Scales to massive scenes : stochastic global bundle adjustment for very large reconstructions (config: [Stochastic bundle](doc/configuration.md#stochastic-bundle)) and split-into-submodels processing — see [Large datasets](doc/large_datasets.md) +- Georeferencing : GPS and GCP referencing with adaptive datum shift compensation — config: [`bundle_compensate_gps_bias`, `align_method`](doc/configuration.md#gps--gcp-alignment); see [Georeferencing](doc/georeferencing.md) +- Cameras : perspective, fisheye and panorama (spherical) camera models support. Many different distortion flavors (Brown and others). Rolling-shutter correction via two-step compensation. — see [Camera models](doc/geometry.md#camera-models) and the [supported projection types](doc/using.md#extract_metadata) (config: [`default_projection_type`](doc/configuration.md#metadata)) +- Rig : full rig support. Can automatically calibrate the rig geometry. — see [Rig models](doc/rig.md) / [`create_rig`](doc/using.md#create_rig) (config: [`optimize_rig_parameters`](doc/configuration.md#bundle-adjustment), [Rigs](doc/configuration.md#rigs)) +- JSON and GeoJSON export — plus interoperability exporters for COLMAP, OpenMVS, Bundler, VisualSFM, PMVS and PLY — see [the exporters](doc/using.md#other-exporters) and [`export_geocoords`](doc/using.md#export_geocoords) + +### 🍇 Dense + +![Dense Reconstruction](doc/images/dense.png) + +> How-to: [Dense reconstruction & 2D maps](doc/dense.md) · math: [Dense matching notes](doc/dense_matching.md) + +- GPU (OpenCL) PatchMatch — [`compute_depthmaps`](doc/using.md#dense-reconstruction-dense_clustering-compute_depthmaps-fuse_depthmaps-dense_merging); config: [Depth estimation](doc/configuration.md#depth-estimation-patchmatch-opencl) +- GPU (OpenCL) SVO TSDF depthmap fusion — [`fuse_depthmaps`](doc/dense.md); config: [Fusion](doc/configuration.md#fusion) +- TSDF photometric refinement — config: [`depthmap_fusion_svo_refine_enabled`](doc/configuration.md#fusion) +- Dual Contouring (Surface Nets) surface extraction for sharp and well defined point-clouds and meshes +- LAS, LAZ, PLY export for point cloud — config: [`dense_pointcloud_export_las`, `dense_pointcloud_export_laz`](doc/configuration.md#dense-disk-reclamation-and-export) +- PLY export for the mesh (`mesh.ply`) — config: [`depthmap_fusion_mesh_enabled`](doc/configuration.md#fusion) +- Streaming octree (Potree-style) tiles for the web/point-cloud viewer (`point_cloud/`) — config: [Octree tiling](doc/configuration.md#octree-tiling) + +### 🧇 Ortho/DSM + +![DSM and Ortho Extraction](doc/images/dsm_ortho.png) + +> See [2D maps: DSM and orthophoto](doc/dense.md#2d-maps-dsm-and-orthophoto) and [Georeferencing & GIS outputs](doc/georeferencing.md) + +- Direct, TSDF-based DSM and orthophoto rendering — config: [DSM and orthophoto](doc/configuration.md#dsm-and-orthophoto) (`dsm_enabled`, hole filling, coherence-enhancing shock filter, robust multi-view color baking) +- Accurate geo-referencing to output coordinate system (projection 3rd-degree polynomial, TPS fallback) — output CRS derived automatically; see [the output coordinate system](doc/georeferencing.md#the-output-coordinate-system) and `dense_merging --georeferenced` +- GeoTIFF export — `dsm.tif` / `ortho.tif`; see the [output reference](doc/dense.md#output-reference) + +### 🩺 Quality Report + +[Example Report](doc/images/report.pdf) + +> See [Statistics & quality report](doc/quality_report.md) and [reporting JSON](doc/reporting.md) + +- SfM quality metrics tables — [reconstruction details](doc/quality_report.md#reconstruction-details) +- Overlap map +- DSM/Ortho map +- Global GPS and GCP errors table — [GPS/GCP errors details](doc/quality_report.md#gpsgcp-errors-details) +- Detailed GCP errors table (control points vs. checkpoints) +- Localized report : metric/imperial units and en/fr/es/de/it — config: [`report_unit_system`, `report_language`](doc/configuration.md#report-localization) +- PDF export via [`compute_statistics`](doc/using.md#compute_statistics) + [`export_report`](doc/using.md#export_report) + +### 🥽 Visualisation +![Rerun Export](doc/images/rerun.png) +- Viewer-based visualiser (SfM data only) — see [viewer/README.md](viewer/README.md) +- Web/point-cloud viewer via streaming octree tiles (`point_cloud/`) — config: [Octree tiling](doc/configuration.md#octree-tiling) +- Rerun export (SfM) with GPS/GCP data, and quality report. — [`export_rerun`](doc/using.md#export_rerun) + +### ⏱️ Benchmarking + +> Guide: [Benchmarking](doc/benchmark.md) + +- Reproducible per-commit benchmarks — each run builds a chosen commit in an isolated git worktree + dedicated conda env, leaving your checkout untouched +- Runs the pipeline on a configurable set of datasets, SfM-only or with the full dense stages (`--dense`) +- HTML A/B report comparing quality (reconstruction, reprojection, tracks, features, GPS/GCP errors) and per-step timings against a reference commit +- Crash-safe and resumable — resume an interrupted run, re-run from a given step, bootstrap early steps from a previous run, or regenerate the report only + +## 🩹 Bugfixes + +## 🫣 Known Issues +- Performance on Macs is suboptimal : PatchMatch kernels needs proper Metal reimplementation, as the OpenCL kernels (mostly the PatchMatch ones in `src/lib/dense/opencl_kernels.h`) are not optimized for Apple Silicon (M1/.../M5). +- Mesh support is experimental and still very sub-optimal — the Surface Nets mesh (config: [`depthmap_fusion_mesh_enabled`](doc/configuration.md#fusion)) is off by default (opt-in) and can leave holes in empty regions +- TSDF photometric refinement doesn't bring substantial improvement (config: [`depthmap_fusion_svo_refine_enabled`](doc/configuration.md#fusion), `false` by default) +- The global equaliser flatten a bit much the dynamic in case of highlights +- Windows conda locks are out-of-date and Windows support is unknown (although it's easily fixable) +- The split-merge is the old one and not out-of-core, neither is the dense/ortho/DSM + +🛸 There is more on the table : + - Proper mesh support + - Revamped split/merge for massive datasets (fully out-of-core) + - Preview mode (10-20 images/sec. target) + - Gaussian Splats ! diff --git a/annotation_gui_gcp/lib/GUI.py b/annotation_gui_gcp/lib/GUI.py index f9953f4d8..1676201a1 100644 --- a/annotation_gui_gcp/lib/GUI.py +++ b/annotation_gui_gcp/lib/GUI.py @@ -1,3 +1,4 @@ +# pyre-unsafe import os import random import subprocess @@ -6,12 +7,13 @@ from collections import defaultdict import flask -from annotation_gui_gcp.lib.views.cad_view import CADView -from annotation_gui_gcp.lib.views.cp_finder_view import ControlPointFinderView -from annotation_gui_gcp.lib.views.image_view import ImageView -from annotation_gui_gcp.lib.views.tools_view import ToolsView from opensfm import dataset +from .views.cad_view import CADView +from .views.cp_finder_view import ControlPointFinderView +from .views.image_view import ImageView +from .views.tools_view import ToolsView + class Gui: def __init__( diff --git a/annotation_gui_gcp/lib/gcp_manager.py b/annotation_gui_gcp/lib/gcp_manager.py index 1a22e1ae7..3472bf21c 100644 --- a/annotation_gui_gcp/lib/gcp_manager.py +++ b/annotation_gui_gcp/lib/gcp_manager.py @@ -1,3 +1,4 @@ +# pyre-unsafe import json import os import typing as t @@ -70,7 +71,7 @@ def __repr__(self): def observation_to_json( - obs: t.Union[PointMeasurement3D, PointMeasurement] + obs: t.Union[PointMeasurement3D, PointMeasurement], ) -> t.Dict[str, t.Any]: if isinstance(obs, PointMeasurement): return { @@ -89,7 +90,7 @@ def observation_to_json( def observation_from_json( - obs: t.Dict[str, t.Any] + obs: t.Dict[str, t.Any], ) -> t.Union[PointMeasurement3D, PointMeasurement]: if "projection" in obs: return PointMeasurement( diff --git a/annotation_gui_gcp/lib/geometry.py b/annotation_gui_gcp/lib/geometry.py index c10b2daa5..edb9b2ddf 100644 --- a/annotation_gui_gcp/lib/geometry.py +++ b/annotation_gui_gcp/lib/geometry.py @@ -1,7 +1,9 @@ -from opensfm import dataset -from numpy import ndarray +# pyre-unsafe from typing import Dict, Tuple +from numpy import ndarray +from opensfm import dataset + def get_all_track_observations(gcp_database, track_id: str) -> Dict[str, ndarray]: print(f"Getting all observations of track {track_id}") @@ -11,7 +13,9 @@ def get_all_track_observations(gcp_database, track_id: str) -> Dict[str, ndarray return {shot_id: obs.point for shot_id, obs in track_obs.items()} -def get_tracks_visible_in_image(gcp_database, image_key, min_len: int=5) -> Dict[str, Tuple[ndarray, int]]: +def get_tracks_visible_in_image( + gcp_database, image_key, min_len: int = 5 +) -> Dict[str, Tuple[ndarray, int]]: print(f"Getting track observations visible in {image_key}") data = dataset.DataSet(gcp_database.path) tracks_manager = data.load_tracks_manager() diff --git a/annotation_gui_gcp/lib/image_manager.py b/annotation_gui_gcp/lib/image_manager.py index bd4d63922..494eeda7a 100644 --- a/annotation_gui_gcp/lib/image_manager.py +++ b/annotation_gui_gcp/lib/image_manager.py @@ -1,3 +1,4 @@ +# pyre-unsafe import typing as t from io import BytesIO diff --git a/annotation_gui_gcp/lib/views/cad_view.py b/annotation_gui_gcp/lib/views/cad_view.py index fb3e46c79..23a6c118c 100644 --- a/annotation_gui_gcp/lib/views/cad_view.py +++ b/annotation_gui_gcp/lib/views/cad_view.py @@ -1,13 +1,15 @@ +# pyre-unsafe import json import logging from pathlib import Path from typing import Any, Dict, Tuple import rasterio -from annotation_gui_gcp.lib.views.web_view import WebView, distinct_colors from flask import send_file from PIL import ImageColor +from ..views.web_view import distinct_colors, WebView + logger: logging.Logger = logging.getLogger(__name__) @@ -30,7 +32,7 @@ def __init__( route_prefix, path_cad_file, is_geo_reference=False, - )-> None: + ) -> None: super().__init__(main_ui, web_app, route_prefix) self.main_ui = main_ui @@ -59,7 +61,7 @@ def process_client_message(self, data: Dict[str, Any]) -> None: else: raise ValueError(f"Unknown event {event}") - def add_remove_update_point_observation(self, point_coordinates=None)->None: + def add_remove_update_point_observation(self, point_coordinates=None) -> None: gcp_manager = self.main_ui.gcp_manager active_gcp = self.main_ui.curr_point if active_gcp is None: @@ -97,17 +99,17 @@ def add_remove_update_point_observation(self, point_coordinates=None)->None: def display_points(self) -> None: pass - def refocus(self, lat, lon)->None: + def refocus(self, lat, lon) -> None: x, y, z = self.latlon_to_xyz(lat, lon) self.send_sse_message( {"x": x, "y": y, "z": z}, event_type="move_camera", ) - def highlight_gcp_reprojection(self, *args, **kwargs)->None: + def highlight_gcp_reprojection(self, *args, **kwargs) -> None: pass - def populate_image_list(self, *args, **kwargs)->None: + def populate_image_list(self, *args, **kwargs) -> None: pass def latlon_to_xyz(self, lat, lon) -> Tuple[float, float, float]: @@ -128,13 +130,13 @@ def xyz_to_latlon(self, x, y, z) -> Tuple[float, float, float]: lons, lats, alts = rasterio.warp.transform(self.crs, "EPSG:4326", [x], [y], [z]) return lats[0], lons[0], alts[0] - def load_georeference_metadata(self, path_cad_model)->None: + def load_georeference_metadata(self, path_cad_model) -> None: metadata = _load_georeference_metadata(path_cad_model) self.scale = metadata["scale"] self.crs = metadata["crs"] self.offset = metadata["offset"] - def sync_to_client(self)->None: + def sync_to_client(self) -> None: """ Sends all the data required to initialize or sync the CAD view """ @@ -151,7 +153,11 @@ def sync_to_client(self)->None: for point_id, coords in visible_points_coords.items(): hex_color = distinct_colors[divmod(hash(point_id), 19)[1]] color = ImageColor.getrgb(hex_color) - data["annotations"][point_id] = {"coordinates": coords[:-1], "precision": coords[-1], "color": color} + data["annotations"][point_id] = { + "coordinates": coords[:-1], + "precision": coords[-1], + "color": color, + } # Add the 3D reprojections of the points fn_reprojections = Path( diff --git a/annotation_gui_gcp/lib/views/cp_finder_view.py b/annotation_gui_gcp/lib/views/cp_finder_view.py index af6f3b0a6..42705e7e6 100644 --- a/annotation_gui_gcp/lib/views/cp_finder_view.py +++ b/annotation_gui_gcp/lib/views/cp_finder_view.py @@ -1,6 +1,7 @@ +# pyre-unsafe import typing as t -from annotation_gui_gcp.lib.views.image_view import ImageView +from .image_view import ImageView class ControlPointFinderView(ImageView): diff --git a/annotation_gui_gcp/lib/views/image_view.py b/annotation_gui_gcp/lib/views/image_view.py index 16dddbed8..0465eaaea 100644 --- a/annotation_gui_gcp/lib/views/image_view.py +++ b/annotation_gui_gcp/lib/views/image_view.py @@ -1,6 +1,7 @@ -from typing import Dict, Any +# pyre-unsafe +from typing import Any, Dict -from annotation_gui_gcp.lib.views.web_view import WebView, distinct_colors +from .web_view import distinct_colors, WebView def point_color(point_id: str) -> str: diff --git a/annotation_gui_gcp/lib/views/tools_view.py b/annotation_gui_gcp/lib/views/tools_view.py index 9a70b0482..263a2fff1 100644 --- a/annotation_gui_gcp/lib/views/tools_view.py +++ b/annotation_gui_gcp/lib/views/tools_view.py @@ -1,6 +1,7 @@ -from typing import Dict, Any +# pyre-unsafe +from typing import Any, Dict -from annotation_gui_gcp.lib.views.web_view import WebView +from .web_view import WebView class ToolsView(WebView): diff --git a/annotation_gui_gcp/lib/views/web_view.py b/annotation_gui_gcp/lib/views/web_view.py index a2f93107c..0505ff62f 100644 --- a/annotation_gui_gcp/lib/views/web_view.py +++ b/annotation_gui_gcp/lib/views/web_view.py @@ -1,9 +1,10 @@ +# pyre-unsafe import abc import json import time from queue import Queue -from flask import Response, jsonify, render_template, request +from flask import jsonify, render_template, request, Response distinct_colors = [ "#46f0f0", diff --git a/annotation_gui_gcp/main.py b/annotation_gui_gcp/main.py index aad365118..949a99e74 100644 --- a/annotation_gui_gcp/main.py +++ b/annotation_gui_gcp/main.py @@ -1,16 +1,19 @@ +# pyre-unsafe import argparse import json import typing as t -from collections import OrderedDict, defaultdict +from collections import defaultdict, OrderedDict from os import PathLike from pathlib import Path from typing import Union import numpy as np -from annotation_gui_gcp.lib import GUI -from annotation_gui_gcp.lib.gcp_manager import GroundControlPointManager -from annotation_gui_gcp.lib.image_manager import ImageManager from flask import Flask +from mapillary.opensfm.annotation_gui_gcp.lib import GUI +from mapillary.opensfm.annotation_gui_gcp.lib.gcp_manager import ( + GroundControlPointManager, +) +from mapillary.opensfm.annotation_gui_gcp.lib.image_manager import ImageManager from opensfm import dataset, io diff --git a/annotation_gui_gcp/run_ba.py b/annotation_gui_gcp/run_ba.py index 5e4ad9f09..b7eb0a561 100644 --- a/annotation_gui_gcp/run_ba.py +++ b/annotation_gui_gcp/run_ba.py @@ -1,3 +1,4 @@ +# pyre-unsafe import argparse import json import logging @@ -8,10 +9,16 @@ import numpy as np import opensfm.reconstruction as orec -from opensfm import dataset, log, multiview, pygeometry, pymap -from opensfm import reconstruction_helpers as helpers -from opensfm import transformations as tf -from opensfm import types +from opensfm import ( + dataset, + log, + multiview, + pygeometry, + pymap, + reconstruction_helpers as helpers, + transformations as tf, + types, +) from opensfm.align import apply_similarity logger = logging.getLogger(__name__) @@ -36,7 +43,8 @@ def merge_reconstructions(reconstructions, tracks_manager): merged.add_camera(camera) for point in reconstruction.points.values(): - new_point = merged.create_point(f"R{ix_r}_{point.id}", point.coordinates) + new_point = merged.create_point( + f"R{ix_r}_{point.id}", point.coordinates) new_point.color = point.color for shot in reconstruction.shots.values(): @@ -44,7 +52,8 @@ def merge_reconstructions(reconstructions, tracks_manager): try: obsdict = tracks_manager.get_shot_observations(shot.id) except RuntimeError: - logger.warning(f"Shot id {shot.id} missing from tracks_manager!") + logger.warning( + f"Shot id {shot.id} missing from tracks_manager!") continue for track_id, obs in obsdict.items(): merged_track_id = f"R{ix_r}_{track_id}" @@ -81,11 +90,14 @@ def resplit_reconstruction(merged, original_reconstructions): return split -def gcp_geopositional_error(gcps: List[pymap.GroundControlPoint], reconstruction: types.Reconstruction): +def gcp_geopositional_error( + gcps: List[pymap.GroundControlPoint], reconstruction: types.Reconstruction +): coords_reconstruction = triangulate_gcps(gcps, reconstruction) out = {} for ix, gcp in enumerate(gcps): - expected = reconstruction.reference.to_lla(*gcp.lla_vec) if gcp.lla else None + expected = reconstruction.reference.to_lla( + *gcp.lla_vec) if gcp.lla else None triangulated = ( coords_reconstruction[ix] if coords_reconstruction[ix] is not None else None ) @@ -104,7 +116,8 @@ def gcp_geopositional_error(gcps: List[pymap.GroundControlPoint], reconstruction lat, lon, _alt = out[gcp.id]["expected_lla"] expected_xy = reconstruction.reference.to_topocentric(lat, lon, 0) lat, lon, _alt = out[gcp.id]["triangulated_lla"] - triangulated_xy = reconstruction.reference.to_topocentric(lat, lon, 0) + triangulated_xy = reconstruction.reference.to_topocentric( + lat, lon, 0) out[gcp.id]["error_planar"] = np.linalg.norm( np.array(expected_xy) - np.array(triangulated_xy) ) @@ -114,7 +127,9 @@ def gcp_geopositional_error(gcps: List[pymap.GroundControlPoint], reconstruction return out -def triangulate_gcps(gcps: List[pymap.GroundControlPoint], reconstruction: types.Reconstruction): +def triangulate_gcps( + gcps: List[pymap.GroundControlPoint], reconstruction: types.Reconstruction +): coords = [] for gcp in gcps: res = multiview.triangulate_gcp( @@ -123,14 +138,18 @@ def triangulate_gcps(gcps: List[pymap.GroundControlPoint], reconstruction: types reproj_threshold=1, min_ray_angle_degrees=0.1, ) - coords.append(res) + coords.append(res[0] if res is not None else None) return coords -def reproject_gcps(gcps: List[pymap.GroundControlPoint], reconstruction: types.Reconstruction, reproj_threshold): +def reproject_gcps( + gcps: List[pymap.GroundControlPoint], + reconstruction: types.Reconstruction, + reproj_threshold, +): output = {} for gcp in gcps: - point = multiview.triangulate_gcp( + result = multiview.triangulate_gcp( gcp, reconstruction.shots, reproj_threshold=reproj_threshold, @@ -138,12 +157,15 @@ def reproject_gcps(gcps: List[pymap.GroundControlPoint], reconstruction: types.R ) output[gcp.id] = {} n_obs = len(gcp.observations) - if point is None: - logger.info(f"Could not triangulate {gcp.id} with {n_obs} annotations") + if result is None: + logger.info( + f"Could not triangulate {gcp.id} with {n_obs} annotations") continue + point = result[0] for observation in gcp.observations: lat, lon, alt = reconstruction.reference.to_lla(*point) - output[gcp.id][observation.shot_id] = {"lla": [lat, lon, alt], "error": 0} + output[gcp.id][observation.shot_id] = { + "lla": [lat, lon, alt], "error": 0} if observation.shot_id not in reconstruction.shots: continue shot = reconstruction.shots[observation.shot_id] @@ -180,7 +202,8 @@ def compute_gcp_std(gcp_errors): all_errors = [] for gcp_id in gcp_errors: errors = [e["error"] for e in gcp_errors[gcp_id].values()] - logger.info(f"gcp {gcp_id} mean reprojection error = {np.mean(errors)}") + logger.info( + f"gcp {gcp_id} mean reprojection error = {np.mean(errors)}") all_errors.extend(errors) all_errors = [e for e in all_errors if not np.isnan(e)] @@ -217,12 +240,13 @@ def add_gcp_to_bundle( for point in gcp: point_id = "gcp-" + point.id - coordinates = multiview.triangulate_gcp( + tri_result = multiview.triangulate_gcp( point, shots, reproj_threshold=1, min_ray_angle_degrees=0.1, ) + coordinates = tri_result[0] if tri_result is not None else None if coordinates is None: if point.lla: enu = reference.to_topocentric(*point.lla_vec) @@ -230,10 +254,11 @@ def add_gcp_to_bundle( f"Could not triangulate GCP '{point.id}'." f"Using {enu} (derived from lat,lon)" ) - coordinates = enu + coordinates = np.array(enu) else: logger.warning( - "Cannot initialize GCP '{}'." " Ignoring it".format(point.id) + "Cannot initialize GCP '{}'." " Ignoring it".format( + point.id) ) continue @@ -269,21 +294,26 @@ def bundle_with_fixed_images( for point in reconstruction.points.values(): ba.add_point(point.id, point.coordinates, False) - ba.add_point_prior(point.id, point.coordinates, np.array([100.0, 100.0, 100.0]), False) + ba.add_point_prior( + point.id, point.coordinates, np.array([100.0, 100.0, 100.0]), False + ) for shot_id in reconstruction.shots: shot = reconstruction.get_shot(shot_id) for point in shot.get_valid_landmarks(): obs = shot.get_landmark_observation(point) - ba.add_point_projection_observation(shot.id, point.id, obs.point, obs.scale) + ba.add_point_projection_observation( + shot.id, point.id, obs.point, obs.scale) - add_gcp_to_bundle(ba, reconstruction.reference, gcp, gcp_std, reconstruction.shots) + add_gcp_to_bundle(ba, reconstruction.reference, gcp, + gcp_std, reconstruction.shots) ba.set_point_projection_loss_function( config["loss_function"], config["loss_function_threshold"] ) ba.set_internal_parameters_prior_sd( config["exif_focal_sd"], + config["aspect_ratio_sd"], config["principal_point_sd"], config["radial_distortion_k1_sd"], config["radial_distortion_k2_sd"], @@ -382,18 +412,18 @@ def resect_image(im, camera, gcps, reconstruction, data, dst_reconstruction=None obs = _gcp_image_observation(gcp, im) if not obs: continue - gcp_3d_coords = multiview.triangulate_gcp( + gcp_result = multiview.triangulate_gcp( gcp, reconstruction.shots, reproj_threshold=1, min_ray_angle_degrees=0.1, ) - if gcp_3d_coords is None: + if gcp_result is None: continue b = camera.pixel_bearing(obs.projection) bs.append(b) - Xs.append(gcp_3d_coords) + Xs.append(gcp_result[0]) bs = np.array(bs) Xs = np.array(Xs) @@ -420,7 +450,8 @@ def resect_image(im, camera, gcps, reconstruction, data, dst_reconstruction=None R = T[:, :3].T t = -R.dot(T[:, 3]) dst_reconstruction.add_camera(camera) - shot = dst_reconstruction.create_shot(im, camera.id, pygeometry.Pose(R, t)) + shot = dst_reconstruction.create_shot( + im, camera.id, pygeometry.Pose(R, t)) shot.metadata = helpers.get_image_metadata(data, im) return shot else: @@ -487,7 +518,8 @@ def align_3d_annotations_to_reconstruction( model_id, reconstruction, ): - coords_triangulated_gcps = triangulate_gcps(gcps_this_model, reconstruction) + coords_triangulated_gcps = triangulate_gcps( + gcps_this_model, reconstruction) n_triangulated = sum(x is not None for x in coords_triangulated_gcps) if n_triangulated < 3: logger.info(f"{model_id} has {n_triangulated} gcps, not aligning") @@ -504,7 +536,8 @@ def align_3d_annotations_to_reconstruction( # Move / "reproject" the triangulated GCPs to the reference frame of the CAD model for display/interaction gcp_reprojections = {} try: - s, A, b = find_alignment(coords_triangulated_gcps, coords_annotated_gcps) + s, A, b = find_alignment( + coords_triangulated_gcps, coords_annotated_gcps) for gcp, coords in zip(gcps_this_model, coords_triangulated_gcps): gcp_reprojections[gcp.id] = ( (s * A.dot(coords) + b).tolist() if coords is not None else None @@ -616,24 +649,29 @@ def align( reconstructions = data.load_reconstruction(fn_resplit) else: reconstructions = data.load_reconstruction() - reconstructions = [reconstructions[rec_a], reconstructions[rec_b]] + reconstructions = [ + reconstructions[rec_a], reconstructions[rec_b]] coords0 = triangulate_gcps(gcps, reconstructions[0]) coords1 = triangulate_gcps(gcps, reconstructions[1]) n_valid_0 = sum(c is not None for c in coords0) - logger.debug(f"Triangulated {n_valid_0}/{len(gcps)} gcps for rec #{rec_a}") + logger.debug( + f"Triangulated {n_valid_0}/{len(gcps)} gcps for rec #{rec_a}") n_valid_1 = sum(c is not None for c in coords1) - logger.debug(f"Triangulated {n_valid_1}/{len(gcps)} gcps for rec #{rec_b}") + logger.debug( + f"Triangulated {n_valid_1}/{len(gcps)} gcps for rec #{rec_b}") try: s, A, b = find_alignment(coords1, coords0) apply_similarity(reconstructions[1], s, A, b) except ValueError: - logger.warning(f"Could not rigidly align rec #{rec_b} to rec #{rec_a}") + logger.warning( + f"Could not rigidly align rec #{rec_b} to rec #{rec_a}") return logger.info(f"Rigidly aligned rec #{rec_b} to rec #{rec_a}") else: # Image - to - reconstruction annotation reconstructions = data.load_reconstruction() base = reconstructions[rec_a] - resected = resect_annotated_single_images(base, gcps, camera_models, data) + resected = resect_annotated_single_images( + base, gcps, camera_models, data) reconstructions = [base, resected] else: logger.debug( @@ -645,7 +683,8 @@ def align( return logger.debug(f"Aligning annotations, if any, to rec #{rec_a}") - align_external_3d_models_to_reconstruction(data, gcps, reconstructions[0], rec_a) + align_external_3d_models_to_reconstruction( + data, gcps, reconstructions[0], rec_a) # Set the GPS constraint of the moved/resected shots to the manually-aligned position for shot in reconstructions[1].shots.values(): @@ -662,7 +701,7 @@ def align( # Scale the GPS DOP with the number of shots to ensure GCPs are used to align for shot in merged.shots.values(): - shot.metadata.gps_accuracy.value = 0.5 * len(merged.shots) + shot.metadata.gps_accuracy.value = np.full(3, 0.5 * len(merged.shots)) gcp_alignment = {"after_rigid": gcp_geopositional_error(gcps, merged)} logger.info( @@ -680,7 +719,8 @@ def align( logger.info("Running BA on merged reconstructions") # orec.align_reconstruction(merged, None, data.config) - orec.bundle(merged, camera_models, {}, gcp=gcps, config=data.config) + orec.bundle(merged, camera_models, {}, gcp=gcps, + grid_size=0, config=data.config) data.save_reconstruction( [merged], f"reconstruction_gcp_ba_{rec_a}x{rec_b}.json" ) @@ -709,7 +749,8 @@ def align( with open(f"{data.data_path}/gcp_reprojections_{rec_a}x{rec_b}.json", "w") as f: json.dump(gcp_reprojections, f, indent=4, sort_keys=True) - n_bad_gcp_annotations = int(sum(t[2] > px_threshold for t in reprojection_errors)) + n_bad_gcp_annotations = int( + sum(t[2] > px_threshold for t in reprojection_errors)) if n_bad_gcp_annotations > 0: logger.info(f"{n_bad_gcp_annotations} large reprojection errors:") for t in reprojection_errors: @@ -722,7 +763,6 @@ def align( resplit = resplit_reconstruction(merged, reconstructions) data.save_reconstruction(resplit, fn_resplit) if covariance: - # Re-triangulate to remove badly conditioned points n_points = len(merged.points) @@ -734,7 +774,7 @@ def align( data.config["triangulation_min_ray_angle"] = backup logger.info( f"Re-triangulated. Removed {n_points - len(merged.points)}." - f" Kept {int(100*len(merged.points)/n_points)}%" + f" Kept {int(100 * len(merged.points) / n_points)}%" ) data.save_reconstruction( [merged], @@ -746,7 +786,8 @@ def align( # If we have two reconstructions, we do this twice, fixing each one. _rec_ixs = [(0, 1), (1, 0)] if rec_b is not None else [(0, 1)] for rec_ixs in _rec_ixs: - logger.info(f"Running BA with fixed images. Fixing rec #{rec_ixs[0]}") + logger.info( + f"Running BA with fixed images. Fixing rec #{rec_ixs[0]}") fixed_images = set(reconstructions[rec_ixs[0]].shots.keys()) covariance_estimation_valid = bundle_with_fixed_images( merged, @@ -770,7 +811,8 @@ def align( shots_std_this_pair = [] for shot in merged.shots.values(): if shot.id in reconstructions[rec_ixs[1]].shots: - u, std_v = decompose_covariance(shot.covariance[3:, 3:]) + u, std_v = decompose_covariance( + shot.covariance[3:, 3:]) std = np.linalg.norm(std_v) shots_std_this_pair.append((shot.id, std)) logger.debug(f"{shot.id} std: {std}") @@ -834,7 +876,8 @@ def align( ) if n_bad_std != 0 or n_bad_gcp_annotations != 0: if rigid: - logger.info("Positional uncertainty was not calculated. (--rigid was set).") + logger.info( + "Positional uncertainty was not calculated. (--rigid was set).") elif not covariance: logger.info( "Positional uncertainty was not calculated (--covariance not set)." @@ -856,17 +899,18 @@ def align( gcp_reprojections, px_threshold ) gcps_sorted = sorted( - stats_bad_reprojections, key=lambda k: -stats_bad_reprojections[k] + stats_bad_reprojections, key=lambda k: - + stats_bad_reprojections[k] ) for ix, gcp_id in enumerate(gcps_sorted[:5]): n = stats_bad_reprojections[gcp_id] if n > 0: - logger.info(f"#{ix+1} - {gcp_id}: {n} bad annotations") + logger.info(f"#{ix + 1} - {gcp_id}: {n} bad annotations") else: logger.info("No annotations with large reprojection errors") -if __name__ == "__main__": +def main() -> None: log.setup() args = parse_args() sys.exit( @@ -880,3 +924,7 @@ def align( args.std_threshold, ) ) + + +if __name__ == "__main__": + main() # pragma: no cover diff --git a/badges/coverage-summary.json b/badges/coverage-summary.json new file mode 100644 index 000000000..9cf732f9e --- /dev/null +++ b/badges/coverage-summary.json @@ -0,0 +1,36 @@ +{ + "badge": { + "color": "#fe7d37", + "label": "coverage", + "message": "55.4%" + }, + "reports": [ + { + "branch_rate": 46.02076124567474, + "branches_covered": 2128, + "branches_valid": 4624, + "line_rate": 61.93713359111888, + "lines_covered": 9931, + "lines_valid": 16034, + "name": "coverage-python" + }, + { + "branch_rate": 28.73092407077989, + "branches_covered": 12275, + "branches_valid": 42724, + "line_rate": 51.580355851294655, + "lines_covered": 14263, + "lines_valid": 27652, + "name": "coverage-cpp" + } + ], + "total": { + "branch_rate": 30.41944749514235, + "branches_covered": 14403, + "branches_valid": 47348, + "line_rate": 55.381586778372935, + "lines_covered": 24194, + "lines_valid": 43686, + "name": "total" + } +} diff --git a/badges/coverage.svg b/badges/coverage.svg new file mode 100644 index 000000000..fac4d68ec --- /dev/null +++ b/badges/coverage.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 55.4% + 55.4% + + diff --git a/benchmark/__init__.py b/benchmark/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/benchmark/benchmark_example.json b/benchmark/benchmark_example.json new file mode 100644 index 000000000..278a78cb1 --- /dev/null +++ b/benchmark/benchmark_example.json @@ -0,0 +1,8 @@ +{ + "root": "./data", + "datasets": { + "berlin": "terrestrial", + "lund": "terrestrial" + }, + "output_dir": "./benchmark_runs" +} \ No newline at end of file diff --git a/benchmark/compare.py b/benchmark/compare.py new file mode 100644 index 000000000..66655729c --- /dev/null +++ b/benchmark/compare.py @@ -0,0 +1,433 @@ +# pyre-strict +"""HTML comparison report generation for benchmark runs.""" + +import json +import logging +import os +import time +from html import escape +from typing import Any, Dict, List, Optional, Tuple + +logger: logging.Logger = logging.getLogger(__name__) + + +def _open_read(path: str, retries: int = 3, delay: float = 1.0) -> str: + """Read a file with retries, working around NFS attribute-cache staleness. + + On NFSv3 mounts with the default attribute cache, a file just written may + briefly appear inaccessible (Permission denied / stale handle). We force + a stat() to refresh the cached attributes, then retry on transient errors. + """ + for attempt in range(retries): + try: + os.stat(path) # force NFS attribute refresh + with open(path, "r") as f: + return f.read() + except (PermissionError, OSError) as exc: + if attempt < retries - 1: + logger.warning( + "Retrying read of %s (attempt %d/%d): %s", + path, attempt + 1, retries, exc, + ) + time.sleep(delay) + else: + raise + return "" # unreachable, keeps pyre happy + + +def load_run_stats(run_dir: str) -> Dict[str, Any]: + """Load stats.json for each dataset in a run directory.""" + stats: Dict[str, Any] = {} + meta_path = os.path.join(run_dir, "run_meta.json") + if not os.path.isfile(meta_path): + return stats + + meta = json.loads(_open_read(meta_path)) + + for dataset_name in meta.get("config", {}).get("datasets", []): + stats_path = os.path.join(run_dir, dataset_name, "stats", "stats.json") + if os.path.isfile(stats_path): + stats[dataset_name] = json.loads(_open_read(stats_path)) + else: + stats[dataset_name] = None + return stats + + +def load_run_meta(run_dir: str) -> Optional[Dict[str, Any]]: + """Load run_meta.json from a run directory.""" + meta_path = os.path.join(run_dir, "run_meta.json") + if not os.path.isfile(meta_path): + return None + return json.loads(_open_read(meta_path)) + + +def find_reference_run( + output_dir: str, + current_run_dir: str, + explicit_ref: Optional[str] = None, +) -> Optional[str]: + """Find a reference run directory for comparison. + + If explicit_ref is an existing directory path, use it directly. + If explicit_ref is a commit hash prefix, search output_dir for a matching run. + If explicit_ref is None, pick the most recent run (excluding current). + """ + if explicit_ref: + # Direct path + if os.path.isdir(explicit_ref): + meta_path = os.path.join(explicit_ref, "run_meta.json") + if os.path.isfile(meta_path): + return os.path.abspath(explicit_ref) + + # Search by commit hash prefix + if os.path.isdir(output_dir): + candidates = [] + for name in os.listdir(output_dir): + run_path = os.path.join(output_dir, name) + if not os.path.isdir(run_path): + continue + meta_path = os.path.join(run_path, "run_meta.json") + if not os.path.isfile(meta_path): + continue + # Folder name starts with commit hash prefix + if name.startswith(explicit_ref): + candidates.append((name, run_path)) + if candidates: + candidates.sort(reverse=True) + return candidates[0][1] + return None + + # Default: find latest run excluding current + if not os.path.isdir(output_dir): + return None + + current_name = os.path.basename(os.path.abspath(current_run_dir)) + candidates = [] + for name in os.listdir(output_dir): + if name == current_name: + continue + run_path = os.path.join(output_dir, name) + if not os.path.isdir(run_path): + continue + meta_path = os.path.join(run_path, "run_meta.json") + if not os.path.isfile(meta_path): + continue + candidates.append((name, run_path)) + + if not candidates: + return None + + candidates.sort(reverse=True) + return candidates[0][1] + + +# --------------------------------------------------------------------------- +# Metric definitions +# --------------------------------------------------------------------------- + +# Each metric: (label, json_path, lower_is_better) +# json_path is a dot-separated path into stats.json + +MetricDef = Tuple[str, str, bool] + +RECONSTRUCTION_METRICS: List[MetricDef] = [ + ("Components", "reconstruction_statistics.components", True), + ("Reconstructed Shots", "reconstruction_statistics.reconstructed_shots_count", False), + ("Reconstructed Points", "reconstruction_statistics.reconstructed_points_count", False), + ("Observations", "reconstruction_statistics.observations_count", False), +] + +REPROJECTION_METRICS: List[MetricDef] = [ + ("Reprojection Error (normalized)", + "reconstruction_statistics.reprojection_error_normalized", True), + ("Reprojection Error (pixels)", + "reconstruction_statistics.reprojection_error_pixels", True), + ("Reprojection Error (angular)", + "reconstruction_statistics.reprojection_error_angular", True), +] + +TRACK_METRICS: List[MetricDef] = [ + ("Avg Track Length", "reconstruction_statistics.average_track_length", False), + ("Avg Track Length (>2)", + "reconstruction_statistics.average_track_length_over_two", False), +] + +FEATURE_METRICS: List[MetricDef] = [ + ("Detected Features (mean)", "features_statistics.detected_features.mean", False), + ("Detected Features (median)", + "features_statistics.detected_features.median", False), + ("Reconstructed Features (mean)", + "features_statistics.reconstructed_features.mean", False), + ("Reconstructed Features (median)", + "features_statistics.reconstructed_features.median", False), +] + +GPS_METRICS: List[MetricDef] = [ + ("GPS Error (avg)", "gps_errors.average_error", True), +] + +GCP_METRICS: List[MetricDef] = [ + ("GCP Error (avg)", "gcp_errors.average_error", True), +] + +TIMING_METRICS: List[MetricDef] = [ + ("Feature Extraction (s)", + "processing_statistics.steps_times.Feature Extraction", True), + ("Features Matching (s)", "processing_statistics.steps_times.Features Matching", True), + ("Tracks Merging (s)", "processing_statistics.steps_times.Tracks Merging", True), + ("Reconstruction (s)", "processing_statistics.steps_times.Reconstruction", True), +] + +DENSE_TIMING_METRICS: List[MetricDef] = [ + ("Dense Clustering (s)", + "processing_statistics.steps_times.Dense Clustering", True), + ("Dense Equalize (s)", + "processing_statistics.steps_times.Dense Equalize", True), + ("Dense Depthmaps (s)", + "processing_statistics.steps_times.Dense Depthmaps", True), + ("Dense Fusion (s)", "processing_statistics.steps_times.Dense Fusion", True), + ("Dense Merging (s)", "processing_statistics.steps_times.Dense Merging", True), +] + +TOTAL_TIMING_METRIC: List[MetricDef] = [ + ("Total Time (s)", "processing_statistics.steps_times.Total Time", True), +] + + +def _get_nested(data: Optional[Dict[str, Any]], path: str) -> Any: + """Get a value from a nested dict using dot-separated path.""" + if data is None: + return None + parts = path.split(".") + current: Any = data + for part in parts: + if not isinstance(current, dict): + return None + current = current.get(part) + if current is None: + return None + return current + + +def _fmt(value: Any) -> str: + """Format a metric value for display.""" + if value is None: + return "—" + if isinstance(value, float): + if abs(value) >= 100: + return f"{value:.1f}" + if abs(value) >= 1: + return f"{value:.2f}" + return f"{value:.4f}" + return str(value) + + +def _diff_class( + current: Any, reference: Any, lower_is_better: bool +) -> str: + """Return a CSS class indicating whether the current value is better or worse.""" + if current is None or reference is None: + return "" + try: + c = float(current) + r = float(reference) + except (TypeError, ValueError): + return "" + if c == r: + return "" + if lower_is_better: + return "better" if c < r else "worse" + else: + return "better" if c > r else "worse" + + +CSS = """\ +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + margin: 20px; background: #fafafa; color: #333; } +h1 { font-size: 1.4em; } +h2 { font-size: 1.1em; margin-top: 2em; border-bottom: 1px solid #ccc; padding-bottom: 4px; } +.meta { color: #666; font-size: 0.9em; margin-bottom: 1.5em; } +table { border-collapse: collapse; width: 100%; margin-bottom: 1.5em; font-size: 0.9em; } +th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: right; } +th { background: #f0f0f0; text-align: center; } +td:first-child { text-align: left; font-weight: bold; } +td.label-cell { text-align: left; font-weight: normal; padding-left: 24px; color: #555; } +tr.ref-row td { background: #f8f8f8; } +tr.cur-row td { background: #fff; } +.better { color: #1a7f37; font-weight: bold; } +.worse { color: #cf222e; font-weight: bold; } +.failed { color: #cf222e; font-weight: bold; font-style: italic; } +a { color: #0969da; text-decoration: none; } +a:hover { text-decoration: underline; } +.dataset-header td { background: #e8e8e8; font-weight: bold; } +.dataset-header-failed td { background: #fdd; font-weight: bold; } +""" + + +def _render_metric_table( + title: str, + metrics: List[MetricDef], + datasets: List[str], + current_stats: Dict[str, Any], + reference_stats: Optional[Dict[str, Any]], + run_dir: str, + current_meta: Optional[Dict[str, Any]] = None, + reference_meta: Optional[Dict[str, Any]] = None, +) -> str: + """Render one comparison table section. + + Datasets that failed (no stats) are shown with a FAILED banner. + """ + has_ref = reference_stats is not None + + lines = [f"

{escape(title)}

", "", + ""] + for label, _, _ in metrics: + lines.append(f"") + lines.append("") + + for ds in datasets: + cs = current_stats.get(ds) + rs = reference_stats.get(ds) if reference_stats else None + report_link = f"{ds}/stats/report.pdf" + + # Determine per-dataset success from run_meta + cur_ds_meta = (current_meta or {}).get("datasets", {}).get(ds, {}) + cur_failed = cs is None or cur_ds_meta.get("success") is False + ref_ds_meta = (reference_meta or {}).get("datasets", {}).get(ds, {}) + ref_failed = has_ref and ( + rs is None or ref_ds_meta.get("success") is False) + + # Failed step info + cur_failed_step = cur_ds_meta.get("failed_step", "") + ref_failed_step = ref_ds_meta.get("failed_step", "") + + # Dataset header + colspan = len(metrics) + 2 + header_cls = "dataset-header-failed" if cur_failed else "dataset-header" + ds_label = escape(ds) + if cur_failed: + ds_label += ' FAILED' + if cur_failed_step: + ds_label += f" at {escape(cur_failed_step)}" + ds_label += "" + lines.append( + f'' + ) + + if has_ref: + # Reference row + lines.append( + '') + if ref_failed: + fail_text = "FAILED" + if ref_failed_step: + fail_text += f" at {escape(ref_failed_step)}" + lines.append( + f'') + else: + for _, path, _ in metrics: + val = _get_nested(rs, path) + lines.append(f"") + lines.append("") + + # Current row + lines.append( + '') + if cur_failed and cs is None: + fail_text = "FAILED" + if cur_failed_step: + fail_text += f" at {escape(cur_failed_step)}" + lines.append( + f'') + else: + for _, path, lower_is_better in metrics: + cur_val = _get_nested(cs, path) + ref_val = _get_nested(rs, path) if rs else None + cls = _diff_class(cur_val, ref_val, lower_is_better) + cls_attr = f' class="{cls}"' if cls else "" + lines.append(f"{_fmt(cur_val)}") + lines.append("") + + lines.append("
DatasetRun{escape(label)}
' + f'{ds_label}
reference{fail_text}{_fmt(val)}
current{fail_text}
") + return "\n".join(lines) + + +def generate_comparison_html( + current_stats: Dict[str, Any], + reference_stats: Optional[Dict[str, Any]], + current_meta: Dict[str, Any], + reference_meta: Optional[Dict[str, Any]], + run_dir: str, +) -> str: + """Generate comparison.html and return the output path.""" + raw_datasets = current_meta.get("config", {}).get("datasets", []) + # datasets may be a dict (name -> config_name) or a list + datasets: List[str] = list(raw_datasets.keys()) if isinstance( + raw_datasets, dict) else list(raw_datasets) + + cur_commit = current_meta.get("commit", "unknown")[:8] + cur_date = current_meta.get("date", "unknown") + ref_commit = reference_meta.get("commit", "unknown")[ + :8] if reference_meta else None + ref_date = reference_meta.get( + "date", "unknown") if reference_meta else None + + header_parts = [ + f"Commit: {escape(cur_commit)} — {escape(cur_date)}"] + if ref_commit: + header_parts.append( + f"Reference: {escape(ref_commit)} — {escape(ref_date or '')}" + ) + + # Append dense-stage timings to the timing table when either run executed + # the dense pipeline, so they can be compared like every other step. + dense_run = bool(current_meta.get("dense")) or bool( + (reference_meta or {}).get("dense")) + timing_metrics = TIMING_METRICS + ( + DENSE_TIMING_METRICS if dense_run else []) + TOTAL_TIMING_METRIC + + sections = [ + ("Reconstruction Summary", RECONSTRUCTION_METRICS), + ("Reprojection Errors", REPROJECTION_METRICS), + ("Track Statistics", TRACK_METRICS), + ("Feature Statistics", FEATURE_METRICS), + ("GPS Errors", GPS_METRICS), + ("GCP Errors", GCP_METRICS), + ("Processing Times", timing_metrics), + ] + + tables_html = "" + for title, metrics in sections: + tables_html += _render_metric_table( + title, metrics, datasets, current_stats, reference_stats, run_dir, + current_meta=current_meta, reference_meta=reference_meta, + ) + + html = f"""\ + + + + +OpenSfM Benchmark — {escape(cur_commit)} + + + +

OpenSfM Benchmark Report

+
+{"
".join(header_parts)} +
+{tables_html} + + +""" + + output_path = os.path.join(run_dir, "comparison.html") + with open(output_path, "w") as f: + f.write(html) + + logger.info("Comparison report written to %s", output_path) + return output_path diff --git a/benchmark/config.py b/benchmark/config.py new file mode 100644 index 000000000..2c49c1fa1 --- /dev/null +++ b/benchmark/config.py @@ -0,0 +1,65 @@ +# pyre-strict +"""Benchmark configuration loading and validation.""" + +import json +import os +from dataclasses import dataclass, field +from typing import Dict, List + + +@dataclass +class BenchmarkConfig: + root: str + datasets: Dict[str, str] # dataset_name -> config_name + configs_dir: str # path to benchmark/configs/ with predefined config.yaml files + output_dir: str = "./benchmark_runs" + + def dataset_names(self) -> List[str]: + """Ordered list of dataset names.""" + return list(self.datasets.keys()) + + +def load_config(path: str) -> BenchmarkConfig: + """Load and validate a benchmark configuration from a JSON file.""" + with open(path, "r") as f: + data = json.load(f) + + root = data.get("root") + if not root: + raise ValueError("Config must specify 'root' directory") + root = os.path.abspath(root) + if not os.path.isdir(root): + raise ValueError(f"Root directory does not exist: {root}") + + datasets = data.get("datasets") + if not datasets or not isinstance(datasets, dict): + raise ValueError( + "Config must specify 'datasets' as a dict of {name: config_name}" + ) + + # Resolve configs directory (configs/ at repo root, sibling of benchmark/) + configs_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "configs" + ) + if not os.path.isdir(configs_dir): + raise ValueError(f"Configs directory does not exist: {configs_dir}") + + for name, config_name in datasets.items(): + ds_path = os.path.join(root, name) + if not os.path.isdir(ds_path): + raise ValueError(f"Dataset directory does not exist: {ds_path}") + images_path = os.path.join(ds_path, "images") + if not os.path.isdir(images_path): + raise ValueError(f"Dataset has no images/ directory: {ds_path}") + config_file = os.path.join(configs_dir, f"{config_name}.yaml") + if not os.path.isfile(config_file): + raise ValueError( + f"Config file not found for dataset '{name}': {config_file}" + ) + + output_dir = data.get("output_dir", "./benchmark_runs") + output_dir = os.path.abspath(output_dir) + + return BenchmarkConfig( + root=root, datasets=datasets, configs_dir=configs_dir, output_dir=output_dir + ) diff --git a/benchmark/pipeline.py b/benchmark/pipeline.py new file mode 100644 index 000000000..384b0f7bb --- /dev/null +++ b/benchmark/pipeline.py @@ -0,0 +1,421 @@ +# pyre-strict +"""SfM pipeline execution for benchmarking.""" + +import json +import logging +import os +import shutil +import subprocess +import tempfile +import time +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional + +from benchmark.config import BenchmarkConfig +from benchmark.workspace import ( + cleanup_local_dir, + stage_dataset_local, + sync_dataset_to_nas, +) + +logger: logging.Logger = logging.getLogger(__name__) + +PIPELINE_STEPS: List[str] = [ + "extract_metadata", + "detect_features", + "match_features", + "create_tracks", + "reconstruct", + "compute_statistics", + "export_report", +] + +# Dense-reconstruction stages (opt-in via --dense). ``undistort`` is the +# prerequisite that produces the undistorted dataset the dense stages consume. +# They are inserted right before ``compute_statistics`` so the latter picks up +# their per-stage timing reports into stats.json (and thus the HTML report). +DENSE_STEPS: List[str] = [ + "undistort", + "dense_clustering", + "dense_equalize", + "compute_depthmaps", + "fuse_depthmaps", + "dense_merging", +] + + +def build_pipeline_steps(dense: bool = False) -> List[str]: + """Return the ordered pipeline steps for a run. + + When ``dense`` is True the dense stages (and their ``undistort`` + prerequisite) are spliced in just before ``compute_statistics`` so their + timings flow into stats.json like every other step. + """ + if not dense: + return list(PIPELINE_STEPS) + idx = PIPELINE_STEPS.index("compute_statistics") + return PIPELINE_STEPS[:idx] + DENSE_STEPS + PIPELINE_STEPS[idx:] + + +# Superset of every step that can appear in any run (used for argparse choices). +ALL_PIPELINE_STEPS: List[str] = build_pipeline_steps(dense=True) + +# For each step, the path relative to dataset_path that indicates completion. +# Directories are considered complete when non-empty. +STEP_OUTPUT_FILES: Dict[str, str] = { + "extract_metadata": "exif", + "detect_features": "features", + "match_features": "reports/matches.json", + "create_tracks": "tracks.csv", + "reconstruct": "reconstruction.json", + "undistort": "undistorted/reconstruction.json", + "dense_equalize": "reports/dense_equalize.json", + "dense_clustering": "reports/dense_clustering.json", + "compute_depthmaps": "reports/dense_depthmaps.json", + "fuse_depthmaps": "reports/dense_fusion.json", + "dense_merging": "reports/dense_merging.json", + "compute_statistics": "stats/stats.json", + "export_report": "stats/report.pdf", +} + +# All files/directories a step produces that a subsequent step may need. +# Used to bootstrap a new run from an existing one. The dense stages write +# their artefacts inside ``undistorted/`` (carried over wholesale by the +# undistort symlink), so only their report files are listed here. +STEP_OUTPUTS: Dict[str, List[str]] = { + "extract_metadata": ["exif", "camera_models.json"], + "detect_features": ["features"], + "match_features": ["matches", "reports/matches.json"], + "create_tracks": ["tracks.csv", "reports/tracks.json"], + "reconstruct": ["reconstruction.json", "reports/reconstruction.json"], + "undistort": ["undistorted"], + "dense_equalize": ["reports/dense_equalize.json"], + "dense_clustering": ["reports/dense_clustering.json"], + "compute_depthmaps": ["reports/dense_depthmaps.json"], + "fuse_depthmaps": ["reports/dense_fusion.json"], + "dense_merging": ["reports/dense_merging.json"], + "compute_statistics": ["stats"], + "export_report": [], +} + +# Extra CLI arguments appended to specific steps' invocation. dense_merging is +# run georeferenced so the LAS/LAZ and DSM/ortho products land in the output +# coordinate system. +STEP_EXTRA_ARGS: Dict[str, List[str]] = { + "dense_merging": ["--georeferenced"], +} + + +def _set_oom_score_adj(score: int) -> None: + """Write an OOM score adjustment to /proc/self/oom_score_adj. + + Called as preexec_fn in pipeline subprocesses so the kernel preferentially + kills them under memory pressure rather than the benchmark orchestrator. + """ + try: + with open("/proc/self/oom_score_adj", "w") as f: + f.write(str(score)) + except OSError: + pass + + +def _make_oom_preexec(score: int = 500) -> Callable[[], None]: + def _preexec() -> None: + _set_oom_score_adj(score) + return _preexec + + +def protect_self_from_oom(score: int = -500) -> None: + """Lower the current process's OOM score so it survives memory pressure.""" + _set_oom_score_adj(score) + + +def _refresh_disk_cache(dataset_path: str) -> None: + """ + Force NFS attribute-cache refresh on key dataset files. + """ + for name in ("image_list.txt", "config.yaml"): + p = os.path.join(dataset_path, name) + try: + os.stat(p) + # Touch-read a few bytes to force the NFS client to revalidate + with open(p, "rb") as f: + f.read(1) + except OSError: + pass + # Also stat the directory itself + try: + os.listdir(dataset_path) + except OSError: + pass + + +def bootstrap_dataset( + source_dataset_dir: str, + target_dataset_dir: str, + from_step: str, + dense: bool = False, +) -> None: + """Populate target_dataset_dir with outputs of all steps before from_step. + + Directories are symlinked; individual files are copied. + The target dataset directory must already exist (image_list.txt etc. in place). + """ + steps = build_pipeline_steps(dense) + from_idx = steps.index(from_step) + steps_to_copy = steps[:from_idx] + + old_umask = os.umask(0o000) + try: + for step in steps_to_copy: + for rel_path in STEP_OUTPUTS.get(step, []): + src = os.path.join(source_dataset_dir, rel_path) + dst = os.path.join(target_dataset_dir, rel_path) + + if not os.path.exists(src): + continue + + # Ensure parent directory exists + parent = os.path.dirname(dst) + if parent: + os.makedirs(parent, exist_ok=True, mode=0o777) + + if os.path.lexists(dst): + continue # already bootstrapped + + if os.path.isdir(src): + os.symlink(src, dst) + logger.info(" bootstrap symlink: %s -> %s", dst, src) + else: + import shutil + shutil.copy2(src, dst) + logger.info(" bootstrap copy: %s", rel_path) + finally: + os.umask(old_umask) + + +def is_step_complete(step: str, dataset_path: str) -> bool: + """Return True if the step's output already exists in the dataset directory.""" + rel = STEP_OUTPUT_FILES.get(step) + if not rel: + return False + full_path = os.path.join(dataset_path, rel) + if os.path.isdir(full_path): + return bool(os.listdir(full_path)) + return os.path.isfile(full_path) + + +def save_run_meta(run_meta: Dict[str, Any], run_dir: str) -> None: + meta_path = os.path.join(run_dir, "run_meta.json") + old_umask = os.umask(0o000) + try: + with open(meta_path, "w") as f: + json.dump(run_meta, f, indent=2) + os.chmod(meta_path, 0o666) + finally: + os.umask(old_umask) + + +def run_pipeline( + opensfm_bin: str, + dataset_path: str, + conda_env: str, + resume: bool = False, + from_step: Optional[str] = None, + dense: bool = False, +) -> Dict[str, Any]: + """Run the SfM pipeline on a single dataset. + + Args: + resume: If True and from_step is None, skip steps whose outputs + already exist (crash recovery). + from_step: If set, steps *before* this step are skipped unconditionally + (assumed bootstrapped or already complete); this step and all + subsequent steps are always run. + dense: If True, also run the dense-reconstruction stages. + + Returns a dict with per-step timings and success/failure status. + """ + steps = build_pipeline_steps(dense) + from_idx = steps.index(from_step) if from_step else 0 + + result: Dict[str, Any] = { + "success": True, + "steps": {}, + "failed_step": None, + } + + for step in steps: + step_idx = steps.index(step) + ds_name = os.path.basename(dataset_path) + + # Steps before from_step: skip unconditionally + if from_step and step_idx < from_idx: + logger.info( + " [%s] %s SKIPPED (before --from-step)", ds_name, step) + result["steps"][step] = { + "skipped": True, "reason": "before_from_step"} + continue + + # Steps at/after from_step (or all steps when no from_step): + # if resume and no from_step, also skip already-complete steps + if resume and from_step is None and is_step_complete(step, dataset_path): + logger.info(" [%s] %s SKIPPED (already complete)", ds_name, step) + result["steps"][step] = { + "skipped": True, "reason": "already_complete"} + continue + + logger.info(" [%s] %s ...", ds_name, step) + t0 = time.monotonic() + + # Force disk attribute-cache refresh + _refresh_disk_cache(dataset_path) + + extra_args = STEP_EXTRA_ARGS.get(step, []) + cmd = f"{opensfm_bin} {step} {dataset_path}" + if extra_args: + cmd += " " + " ".join(extra_args) + + try: + subprocess.run( + [ + "conda", "run", "--name", conda_env, + "bash", "-c", + f"umask 0000 && {cmd}", + ], + capture_output=True, + text=True, + check=True, + preexec_fn=_make_oom_preexec(500), + ) + elapsed = time.monotonic() - t0 + result["steps"][step] = { + "wall_time": round(elapsed, 2), + "success": True, + } + logger.info(" [%s] %s done (%.1fs)", ds_name, step, elapsed) + except subprocess.CalledProcessError as e: + elapsed = time.monotonic() - t0 + result["steps"][step] = { + "wall_time": round(elapsed, 2), + "success": False, + "stderr": e.stderr[-2000:] if e.stderr else "", + } + result["success"] = False + result["failed_step"] = step + logger.error( + " [%s] %s FAILED (%.1fs)\n%s", + ds_name, + step, + elapsed, + e.stderr[-500:] if e.stderr else "", + ) + break + + return result + + +def run_all_datasets( + opensfm_bin: str, + run_dir: str, + config: BenchmarkConfig, + commit_hash: str, + conda_env: str, + resume: bool = False, + from_step: Optional[str] = None, + existing_meta: Optional[Dict[str, Any]] = None, + bootstrap_run_dir: Optional[str] = None, + dense: bool = False, + local_staging: bool = False, + scratch_dir: Optional[str] = None, +) -> Dict[str, Any]: + """Run the pipeline on all datasets, saving run_meta.json after each one. + + Args: + bootstrap_run_dir: When from_step is set on a fresh run, copy/symlink + outputs of steps before from_step from this directory. + dense: If True, also run the dense-reconstruction stages. + local_staging: If True, process each dataset on a local scratch disk + and mirror the result tree back to ``run_dir`` once, + so NAS I/O does not dominate the timings. + scratch_dir: Base directory for local staging (default: $TMPDIR). + + Returns the final run metadata dict. + """ + staging_root: Optional[str] = None + if local_staging: + base = scratch_dir or tempfile.gettempdir() + staging_root = os.path.join( + base, "opensfm-bench-" + os.path.basename(os.path.normpath(run_dir)) + ) + os.makedirs(staging_root, exist_ok=True) + logger.info("Local staging enabled — scratch root: %s", staging_root) + run_meta: Dict[str, Any] = existing_meta or { + "commit": commit_hash, + "date": datetime.now(timezone.utc).isoformat(), + "status": "in_progress", + "dense": dense, + "config": { + "root": config.root, + "datasets": config.datasets, + "output_dir": config.output_dir, + }, + "datasets": {}, + } + # Ensure status is reset when re-running + run_meta["status"] = "in_progress" + run_meta["dense"] = dense + save_run_meta(run_meta, run_dir) + + total_t0 = time.monotonic() + + for dataset_name in config.datasets: + nas_dataset_path = os.path.join(run_dir, dataset_name) + logger.info("Running pipeline on %s", dataset_name) + + # Process on local disk when staging; otherwise straight on the NAS. + dataset_path = nas_dataset_path + local_dir: Optional[str] = None + if staging_root is not None: + local_dir = os.path.join(staging_root, dataset_name) + logger.info(" Staging %s on local disk: %s", + dataset_name, local_dir) + stage_dataset_local(nas_dataset_path, local_dir) + dataset_path = local_dir + + try: + if from_step and bootstrap_run_dir: + src_ds = os.path.join(bootstrap_run_dir, dataset_name) + if os.path.isdir(src_ds): + logger.info(" Bootstrapping %s from %s", + dataset_name, src_ds) + bootstrap_dataset( + src_ds, dataset_path, from_step, dense=dense) + pipeline_result = run_pipeline( + opensfm_bin, dataset_path, conda_env, + resume=resume, + from_step=from_step, + dense=dense, + ) + finally: + # Always mirror results back (even on failure) so partial outputs + # and reports are preserved on the NAS, then drop the local copy. + if local_dir is not None: + logger.info(" Moving %s results to %s", + dataset_name, nas_dataset_path) + sync_dataset_to_nas(local_dir, nas_dataset_path) + cleanup_local_dir(local_dir) + + run_meta["datasets"][dataset_name] = pipeline_result + # Persist after each dataset so a crash is recoverable + save_run_meta(run_meta, run_dir) + + run_meta["total_wall_time"] = round(time.monotonic() - total_t0, 2) + run_meta["status"] = "complete" + save_run_meta(run_meta, run_dir) + logger.info("Run metadata written to %s/run_meta.json", run_dir) + + if staging_root is not None: + shutil.rmtree(staging_root, ignore_errors=True) + + return run_meta diff --git a/benchmark/run.py b/benchmark/run.py new file mode 100644 index 000000000..fb60cb470 --- /dev/null +++ b/benchmark/run.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +# pyre-strict +"""Benchmark CLI entry point. + +Usage: + # New run: + python -m benchmark.run --config benchmark.json --commit abc1234 + + # Resume an interrupted run (skip completed steps): + python -m benchmark.run --config benchmark.json --resume /path/to/run_dir + + # Re-run from a specific step in an existing run dir: + python -m benchmark.run --config benchmark.json --resume /path/to/run_dir --from-step reconstruct + + # New run bootstrapped from an existing run (reuse features/matches, re-run from reconstruct): + python -m benchmark.run --config benchmark.json --commit abc1234 --from-step reconstruct + python -m benchmark.run --config benchmark.json --commit abc1234 --from-step reconstruct --bootstrap /path/to/run_dir + + # Compare against an explicit reference: + python -m benchmark.run --config benchmark.json --commit abc1234 --reference /path/to/ref_run + + # Re-generate the report only (no build/pipeline): + python -m benchmark.run --config benchmark.json --resume /path/to/run_dir --report-only + python -m benchmark.run --config benchmark.json --resume /path/to/run_dir --report-only --reference /path/to/ref_run +""" + +import argparse +import logging +import os +import sys +import tempfile +from datetime import datetime, timezone +from typing import Optional + +from benchmark.compare import ( + find_reference_run, + generate_comparison_html, + load_run_meta, + load_run_stats, +) +from benchmark.config import load_config +from benchmark.pipeline import ( + ALL_PIPELINE_STEPS, + DENSE_STEPS, + run_all_datasets, + save_run_meta, + protect_self_from_oom, +) +from benchmark.workspace import ( + build_in_worktree, + cleanup_worktree, + setup_conda_env, + setup_dataset, + setup_worktree, + _resolve_commit, +) + +logger: logging.Logger = logging.getLogger("benchmark") + + +def _find_repo_root() -> str: + """Find the git repository root from this file's location.""" + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _find_bootstrap_run(output_dir: str, commit_hash: str) -> Optional[str]: + """Auto-detect the most recent complete run for a given commit hash.""" + short = commit_hash[:8] + if not os.path.isdir(output_dir): + return None + candidates = [] + for name in os.listdir(output_dir): + if not name.startswith(short): + continue + run_path = os.path.join(output_dir, name) + meta = load_run_meta(run_path) + if meta and meta.get("status") == "complete": + candidates.append((name, run_path)) + if not candidates: + return None + candidates.sort(reverse=True) + return candidates[0][1] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run OpenSfM benchmarks at a specific git commit.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--config", + required=True, + help="Path to benchmark JSON config file.", + ) + + run_mode = parser.add_mutually_exclusive_group(required=True) + run_mode.add_argument( + "--commit", + help="Git commit hash (or ref) to benchmark. Starts a new run.", + ) + run_mode.add_argument( + "--resume", + metavar="RUN_DIR", + help=( + "Resume or re-run from an existing run directory. " + "The commit hash is read from run_meta.json inside RUN_DIR." + ), + ) + + parser.add_argument( + "--from-step", + choices=ALL_PIPELINE_STEPS, + default=None, + metavar="STEP", + help=( + "Start execution from this step, skipping earlier steps. " + "With --resume: re-run from this step in the same directory. " + "With --commit: creates a new run and bootstraps prior step outputs " + "from an existing run (auto-detected or specified via --bootstrap). " + f"Choices: {', '.join(ALL_PIPELINE_STEPS)}" + ), + ) + parser.add_argument( + "--dense", + action="store_true", + help=( + "Also run the dense-reconstruction stages (undistort, " + "dense_clustering, compute_depthmaps, fuse_depthmaps, dense_merging) " + "before compute_statistics, so their timings appear in the report." + ), + ) + parser.add_argument( + "--bootstrap", + metavar="RUN_DIR", + default=None, + help=( + "Only valid with --commit --from-step. Path to an existing run directory " + "to symlink/copy prior step outputs from. If omitted, the most recent " + "run for the same commit is used automatically." + ), + ) + parser.add_argument( + "--local-staging", + action="store_true", + help=( + "Process each dataset on a local scratch disk (see --scratch-dir) " + "and move the results back to the run directory at the end. Use " + "when the run directory is on a network share (NAS) so its I/O does " + "not dominate the timings." + ), + ) + parser.add_argument( + "--scratch-dir", + metavar="DIR", + default=None, + help=( + "Base directory for --local-staging (default: $TMPDIR, usually " + "/tmp). Point it at a fast local disk with room for the dataset's " + "intermediate artefacts." + ), + ) + parser.add_argument( + "--reference", + default=None, + help="Reference run: path to a previous run directory, or a commit hash prefix.", + ) + parser.add_argument( + "--output-dir", + default=None, + help="Override output directory from config (ignored when --resume is used).", + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Enable verbose logging.", + ) + parser.add_argument( + "--report-only", + action="store_true", + help=( + "Only with --resume. Skip worktree/build/pipeline entirely and " + "regenerate the HTML comparison report from existing results." + ), + ) + args = parser.parse_args() + + # Protect this orchestrator process from the OOM killer — pipeline + # subprocesses are given a high OOM score instead, so the kernel + # kills them first under memory pressure rather than us. + protect_self_from_oom(-500) + + if args.bootstrap and not (args.commit and args.from_step): + parser.error("--bootstrap requires --commit and --from-step") + if args.from_step and args.resume and args.bootstrap: + parser.error("--bootstrap is only used with --commit, not --resume") + if args.report_only and not args.resume: + parser.error("--report-only requires --resume") + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", + ) + + # Load and validate config + config = load_config(args.config) + + repo_root = _find_repo_root() + is_resume = args.resume is not None + + # ----------------------------------------------------------------------- + # Determine run directory and commit hash + # ----------------------------------------------------------------------- + if is_resume: + run_dir = os.path.abspath(args.resume) + if not os.path.isdir(run_dir): + logger.error("Resume directory does not exist: %s", run_dir) + sys.exit(1) + + existing_meta = load_run_meta(run_dir) or {} + full_hash: str = existing_meta.get("commit", "") + + # Fall back to parsing the commit hash from the folder name (e.g. "cff716cd_20260422_062256") + if not full_hash: + folder_name = os.path.basename(run_dir) + short_from_name = folder_name.split("_")[0] + if short_from_name: + try: + full_hash = _resolve_commit(short_from_name, repo_root) + logger.info( + "Inferred commit %s from run directory name.", full_hash[:8] + ) + except Exception: + pass + + if not full_hash: + logger.error( + "Cannot determine commit hash from directory name or run_meta.json in %s.", + run_dir, + ) + sys.exit(1) + short_hash = full_hash[:8] + mode_desc = f"Resuming run at {run_dir}" + if args.from_step: + mode_desc += f" from step '{args.from_step}'" + logger.info("%s (commit %s)", mode_desc, short_hash) + else: + if args.output_dir: + config.output_dir = os.path.abspath(args.output_dir) + full_hash = _resolve_commit(args.commit, repo_root) + short_hash = full_hash[:8] + logger.info("Benchmarking commit %s (%s)", short_hash, full_hash) + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + run_name = f"{short_hash}_{timestamp}" + run_dir = os.path.join(config.output_dir, run_name) + old_umask = os.umask(0o000) + os.makedirs(run_dir, exist_ok=True, mode=0o777) + os.umask(old_umask) + logger.info("Run directory: %s", run_dir) + existing_meta = None + + # ----------------------------------------------------------------------- + # Decide whether to run the dense stages. Explicit --dense always wins; a + # resume inherits the resumed run's setting; selecting a dense --from-step + # implies --dense (otherwise that step would not be in the pipeline). + # ----------------------------------------------------------------------- + dense = args.dense or bool((existing_meta or {}).get("dense", False)) + if args.from_step in DENSE_STEPS and not dense: + logger.info("--from-step '%s' is a dense stage; enabling --dense.", + args.from_step) + dense = True + + # Resolve the local-staging scratch directory once (used in run_meta + run). + scratch_dir = os.path.abspath(args.scratch_dir or tempfile.gettempdir()) + if args.local_staging: + logger.info("Local staging enabled (scratch base: %s)", scratch_dir) + + # ----------------------------------------------------------------------- + # Setup worktree, conda env, build, and run pipeline + # (skipped entirely when --report-only is set) + # ----------------------------------------------------------------------- + if args.report_only: + run_meta = existing_meta or load_run_meta(run_dir) or {} + logger.info("Report-only mode — skipping build and pipeline.") + else: + worktree_path = setup_worktree(full_hash, repo_root) + conda_env = None + try: + conda_env = setup_conda_env(worktree_path, full_hash) + build_in_worktree(worktree_path, conda_env) + + # Dataset setup: idempotent — also fixes NAS permissions on re-run + for dataset_name, config_name in config.datasets.items(): + target_dir = os.path.join(run_dir, dataset_name) + source_dir = os.path.join(config.root, dataset_name) + config_file = os.path.join( + config.configs_dir, f"{config_name}.yaml") + setup_dataset(source_dir, target_dir, config_file) + logger.info("Dataset prepared: %s (config: %s)", + dataset_name, config_name) + + # Write initial run_meta.json before pipeline starts (crash-safe) + if not is_resume: + initial_meta = { + "commit": full_hash, + "date": datetime.now(timezone.utc).isoformat(), + "status": "in_progress", + "dense": dense, + "local_staging": args.local_staging, + "config": { + "root": config.root, + "datasets": config.datasets, + "output_dir": config.output_dir, + }, + "datasets": {}, + } + save_run_meta(initial_meta, run_dir) + existing_meta = initial_meta + + opensfm_bin = os.path.join(worktree_path, "bin", "opensfm") + + # Resolve bootstrap source for --commit --from-step + bootstrap_run_dir: Optional[str] = None + if args.from_step and not is_resume: + if args.bootstrap: + bootstrap_run_dir = os.path.abspath(args.bootstrap) + if not os.path.isdir(bootstrap_run_dir): + raise ValueError( + f"Bootstrap directory does not exist: {bootstrap_run_dir}") + else: + bootstrap_run_dir = _find_bootstrap_run( + config.output_dir, full_hash) + if bootstrap_run_dir: + logger.info("Auto-detected bootstrap source: %s", + bootstrap_run_dir) + else: + logger.warning( + "No complete previous run found for commit %s to bootstrap from. " + "Steps before '%s' will be run from scratch.", + short_hash, args.from_step, + ) + + run_meta = run_all_datasets( + opensfm_bin, + run_dir, + config, + full_hash, + conda_env, + resume=is_resume, + from_step=args.from_step, + existing_meta=existing_meta, + bootstrap_run_dir=bootstrap_run_dir, + dense=dense, + local_staging=args.local_staging, + scratch_dir=scratch_dir, + ) + finally: + cleanup_worktree(worktree_path, repo_root, conda_env) + + # ----------------------------------------------------------------------- + # HTML comparison report + # ----------------------------------------------------------------------- + ref_run_dir = find_reference_run( + config.output_dir, run_dir, args.reference) + current_stats = load_run_stats(run_dir) + reference_stats = load_run_stats(ref_run_dir) if ref_run_dir else None + reference_meta = load_run_meta(ref_run_dir) if ref_run_dir else None + + if ref_run_dir: + logger.info("Comparing against reference: %s", ref_run_dir) + else: + logger.info( + "No reference run found — report will show current results only.") + + output_path = generate_comparison_html( + current_stats, reference_stats, run_meta, reference_meta, run_dir + ) + + # ----------------------------------------------------------------------- + # Summary + # ----------------------------------------------------------------------- + total_datasets = len(config.datasets) + succeeded = sum( + 1 for d in run_meta.get("datasets", {}).values() if d.get("success") + ) + logger.info( + "Benchmark complete: %d/%d datasets succeeded (%.1fs total)", + succeeded, + total_datasets, + run_meta.get("total_wall_time", 0), + ) + logger.info("Report: %s", output_path) + + +if __name__ == "__main__": + main() diff --git a/benchmark/workspace.py b/benchmark/workspace.py new file mode 100644 index 000000000..6aad4244c --- /dev/null +++ b/benchmark/workspace.py @@ -0,0 +1,341 @@ +# pyre-strict +"""Git worktree management and dataset directory setup.""" + +import logging +import os +import platform +import shutil +import subprocess +from typing import List, Optional, Tuple + +logger: logging.Logger = logging.getLogger(__name__) + +CONDA_ENV_PREFIX = "opensfm-bench-" + +IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "tif", "tiff", "pgm", "pnm", "gif"} + +COPYABLE_FILES = [ + "gcp_list.txt", + "ground_control_points.json", + "camera_models_overrides.json", + "exif_overrides.json", +] + +COPYABLE_DIRS = [ + "masks", +] + + +def _benchmark_worktree_dir(repo_root: str) -> str: + """Return the directory used to store benchmark worktrees.""" + return os.path.join(repo_root, ".benchmark-worktrees") + + +def _declared_submodule_paths(worktree_path: str) -> List[str]: + """List submodule paths declared in the worktree's .gitmodules file.""" + gitmodules_path = os.path.join(worktree_path, ".gitmodules") + if not os.path.isfile(gitmodules_path): + return [] + + result = subprocess.run( + [ + "git", + "config", + "--file", + ".gitmodules", + "--get-regexp", + r"^submodule\..*\.path$", + ], + cwd=worktree_path, + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 1 and not result.stdout.strip(): + return [] + result.check_returncode() + + paths = [] + for line in result.stdout.splitlines(): + _, path = line.split(None, 1) + paths.append(path.strip()) + return paths + + +def _initialize_declared_submodules(worktree_path: str) -> None: + """Initialize only submodules declared in .gitmodules. + + Some benchmarked commits may contain stray gitlinks under benchmark + artifacts. Restricting the update to declared paths keeps real submodules + working without tripping over those invalid entries. + """ + submodule_paths = _declared_submodule_paths(worktree_path) + if not submodule_paths: + logger.info("No declared submodules found in worktree") + return + + logger.info("Initializing %d declared submodule(s) in worktree", + len(submodule_paths)) + subprocess.run( + ["git", "submodule", "update", "--init", + "--recursive", "--", *submodule_paths], + cwd=worktree_path, + check=True, + ) + + +def _resolve_commit(commit: str, repo_root: str) -> str: + """Resolve a commit reference to a full hash.""" + result = subprocess.run( + ["git", "rev-parse", commit], + cwd=repo_root, + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def setup_worktree(commit: str, repo_root: str) -> str: + """Create a git worktree for the given commit. Returns the worktree path.""" + full_hash = _resolve_commit(commit, repo_root) + worktree_dir = _benchmark_worktree_dir(repo_root) + os.makedirs(worktree_dir, exist_ok=True) + worktree_path = os.path.join(worktree_dir, full_hash[:12]) + + if os.path.isdir(worktree_path): + logger.info("Removing existing worktree at %s", worktree_path) + subprocess.run( + ["git", "worktree", "remove", worktree_path, "--force"], + cwd=repo_root, + check=True, + ) + + logger.info("Creating worktree for %s at %s", full_hash[:8], worktree_path) + subprocess.run( + ["git", "worktree", "add", "--detach", worktree_path, full_hash], + cwd=repo_root, + check=True, + ) + + # Initialize submodules (e.g. pybind11) in the worktree + _initialize_declared_submodules(worktree_path) + + return worktree_path + + +def _conda_env_name(commit_hash: str) -> str: + """Generate a unique conda environment name for a benchmark run.""" + return f"{CONDA_ENV_PREFIX}{commit_hash[:12]}" + + +def _detect_lock_file(worktree_path: str) -> Optional[str]: + """Check for conda lock files in the worktree. Returns the path if found.""" + system = platform.system().lower() # linux, darwin + arch = platform.machine() # x86_64, aarch64 + # Try platform-specific lock file first, then generic + candidates = [ + f"conda-{system}-{arch}.lock", + f"conda-{system}-64.lock", + "conda-lock.yml", + ] + for name in candidates: + path = os.path.join(worktree_path, name) + if os.path.isfile(path): + return path + return None + + +def setup_conda_env(worktree_path: str, commit_hash: str) -> str: + """Create a conda environment for the worktree's dependencies. + + Checks for lock files first (newer setup), falls back to conda.yml. + Returns the conda environment name. + """ + env_name = _conda_env_name(commit_hash) + + # Remove existing env if present + subprocess.run( + ["conda", "env", "remove", "--name", env_name, "--yes"], + capture_output=True, + check=False, + ) + + lock_file = _detect_lock_file(worktree_path) + conda_yml = os.path.join(worktree_path, "conda.yml") + + if lock_file: + logger.info("Creating conda env '%s' from lock file: %s", + env_name, lock_file) + subprocess.run( + ["conda", "create", "--name", env_name, "--file", lock_file, "--yes"], + check=True, + ) + elif os.path.isfile(conda_yml): + logger.info("Creating conda env '%s' from conda.yml", env_name) + subprocess.run( + ["conda", "env", "create", "--file", + conda_yml, "--name", env_name, "--yes"], + check=True, + ) + else: + raise FileNotFoundError( + f"No conda lock file or conda.yml found in worktree {worktree_path}" + ) + + return env_name + + +def build_in_worktree(worktree_path: str, conda_env: str) -> None: + """Build OpenSfM in the worktree within the dedicated conda env.""" + logger.info("Building OpenSfM in worktree %s (env: %s)", + worktree_path, conda_env) + subprocess.run( + ["conda", "run", "--name", conda_env, + "pip", "install", "-e", "."], + cwd=worktree_path, + check=True, + ) + + +def cleanup_worktree(worktree_path: str, repo_root: str, conda_env: Optional[str] = None) -> None: + """Remove the worktree and its conda environment.""" + logger.info("Removing worktree %s", worktree_path) + subprocess.run( + ["git", "worktree", "remove", worktree_path, "--force"], + cwd=repo_root, + check=False, + ) + + if conda_env: + logger.info("Removing conda env '%s'", conda_env) + subprocess.run( + ["conda", "env", "remove", "--name", conda_env, "--yes"], + capture_output=True, + check=False, + ) + + +def _list_images(images_dir: str) -> List[str]: + """List image files in a directory, sorted.""" + files = [] + for name in sorted(os.listdir(images_dir)): + ext = name.rsplit(".", 1)[-1].lower() if "." in name else "" + if ext in IMAGE_EXTENSIONS: + files.append(os.path.join(images_dir, name)) + return files + + +def setup_dataset(source_dir: str, target_dir: str, config_file: Optional[str] = None) -> None: + """Create a lightweight benchmark dataset directory. + + Generates image_list.txt pointing to the source images via absolute paths, + copies ancillary files (gcp, etc.) from the source, and installs the + benchmark config file with the machine's CPU count as 'processes'. + """ + # Use permissive umask so files are readable/writable across processes + # (needed for NAS mounts where conda run may have different effective user) + old_umask = os.umask(0o000) + try: + _setup_dataset_inner(source_dir, target_dir, config_file) + finally: + os.umask(old_umask) + + +def _setup_dataset_inner(source_dir: str, target_dir: str, config_file: Optional[str] = None) -> None: + os.makedirs(target_dir, exist_ok=True, mode=0o777) + os.chmod(target_dir, 0o777) + + # Generate image_list.txt with absolute paths to source images + images_dir = os.path.join(source_dir, "images") + image_paths = _list_images(images_dir) + if not image_paths: + raise ValueError(f"No images found in {images_dir}") + + image_list_path = os.path.join(target_dir, "image_list.txt") + with open(image_list_path, "w") as f: + for img_path in image_paths: + f.write(img_path + "\n") + os.chmod(image_list_path, 0o666) + + # Copy the benchmark config and append processes count + if config_file and os.path.isfile(config_file): + target_config = os.path.join(target_dir, "config.yaml") + shutil.copy2(config_file, target_config) + ncpus = os.cpu_count() or 1 + with open(target_config, "a") as f: + f.write(f"\nprocesses: {ncpus}\n") + os.chmod(target_config, 0o666) + logger.info("Config %s installed with processes=%d", + config_file, ncpus) + + # Copy ancillary files + for filename in COPYABLE_FILES: + src = os.path.join(source_dir, filename) + dst = os.path.join(target_dir, filename) + if os.path.isfile(src): + shutil.copy2(src, dst) + os.chmod(dst, 0o666) + + # Copy ancillary directories + for dirname in COPYABLE_DIRS: + src = os.path.join(source_dir, dirname) + if os.path.isdir(src): + shutil.copytree(src, os.path.join( + target_dir, dirname), dirs_exist_ok=True) + + +def stage_dataset_local(nas_dataset_dir: str, local_dataset_dir: str) -> None: + """Create ``local_dataset_dir`` seeded from the NAS dataset directory. + + Copies whatever already exists on the NAS — the lightweight setup files for + a fresh run, or prior step outputs when resuming — so the pipeline can then + run entirely against local disk. Symlinks (e.g. bootstrapped step outputs) + are preserved as-is. + """ + old_umask = os.umask(0o000) + try: + parent = os.path.dirname(local_dataset_dir) + if parent: + os.makedirs(parent, exist_ok=True, mode=0o777) + # Start from a clean slate in case a previous staging was interrupted. + if os.path.lexists(local_dataset_dir): + shutil.rmtree(local_dataset_dir, ignore_errors=True) + if os.path.isdir(nas_dataset_dir): + shutil.copytree(nas_dataset_dir, local_dataset_dir, symlinks=True) + else: + os.makedirs(local_dataset_dir, exist_ok=True, mode=0o777) + finally: + os.umask(old_umask) + + +def sync_dataset_to_nas(local_dataset_dir: str, nas_dataset_dir: str) -> None: + """Mirror the locally-processed dataset back to its NAS location. + + The local tree is the source of truth after a run, so the NAS copy is + replaced wholesale — this also propagates files deleted during processing + (e.g. reclaimed raw depthmaps). Refuses to wipe the NAS copy if the local + directory is missing, guarding against destroying results on a staging bug. + """ + if not os.path.isdir(local_dataset_dir): + logger.warning( + "Local staging dir %s missing; leaving NAS copy untouched.", + local_dataset_dir, + ) + return + + old_umask = os.umask(0o000) + try: + parent = os.path.dirname(nas_dataset_dir) + if parent: + os.makedirs(parent, exist_ok=True, mode=0o777) + if os.path.isdir(nas_dataset_dir): + shutil.rmtree(nas_dataset_dir) + shutil.copytree(local_dataset_dir, nas_dataset_dir, symlinks=True) + finally: + os.umask(old_umask) + + +def cleanup_local_dir(local_dir: str) -> None: + """Remove a local staging directory, ignoring errors.""" + shutil.rmtree(local_dir, ignore_errors=True) diff --git a/bin/import_colmap.py b/bin/import_colmap.py index 9388883f4..95d86368d 100755 --- a/bin/import_colmap.py +++ b/bin/import_colmap.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +# pyre-unsafe + # Snippets to read from the colmap database taken from: # https://github.com/colmap/colmap/blob/ad7bd93f1a27af7533121aa043a167fe1490688c / # scripts/python/export_to_bundler.py @@ -21,7 +23,22 @@ import opensfm.actions.undistort as osfm_u from matplotlib import cm from mpl_toolkits.axes_grid1 import make_axes_locatable -from opensfm import dataset, features, pygeometry, pymap, types + +try: + from opensfm import dataset, features, pygeometry, pymap, types +except ImportError: + # If ImportError, try importing without 'types' + try: + from opensfm import dataset, features, pygeometry, pymap + + types = None + except ImportError as e: + print(f"Fallback import failed: {e}") + dataset = features = pygeometry = pymap = types = None +except Exception as e: + print(f"Unexpected error importing OpenSfM: {e}") + dataset = features = pygeometry = pymap = types = None + EXPORT_DIR_NAME = "opensfm_export" logger = logging.getLogger(__name__) @@ -60,7 +77,8 @@ def compute_and_save_undistorted_reconstruction( urec.add_camera(ucamera) ushot = osfm_u.get_shot_with_different_camera(urec, shot, image_format) if tracks_manager: - osfm_u.add_subshot_tracks(tracks_manager, utracks_manager, shot, ushot) + osfm_u.add_subshot_tracks( + tracks_manager, utracks_manager, shot, ushot) undistorted_shots.append(ushot) image = data.load_image(shot.id, unchanged=True, anydepth=True) @@ -199,9 +217,11 @@ def import_features(db, data, image_map, camera_map): for row in cursor: image_id, n_rows, n_cols, arr = row filename, _ = image_map[image_id] - descriptors = np.fromstring(arr, dtype=np.uint8).reshape((n_rows, n_cols)) + descriptors = np.fromstring( + arr, dtype=np.uint8).reshape((n_rows, n_cols)) kp = keypoints[image_id] - features_data = features.FeaturesData(kp, descriptors, colors[image_id], None) + features_data = features.FeaturesData( + kp, descriptors, colors[image_id], None) data.save_features(filename, features_data) cursor.close() @@ -212,7 +232,8 @@ def import_matches(db, data, image_map): cursor = db.cursor() min_matches = 1 cursor.execute( - "SELECT pair_id, data FROM two_view_geometries WHERE rows>=?;", (min_matches,) + "SELECT pair_id, data FROM two_view_geometries WHERE rows>=?;", ( + min_matches,) ) matches_per_im1 = {m[0]: {} for m in image_map.values()} @@ -247,7 +268,8 @@ def import_cameras_reconstruction(path_cameras, rec): n_params = camera_models[camera_model_id][1] for _ in range(n_params): params.append(unpack(" udata.config["depthmap_max_depth"]] = 0 @@ -489,7 +516,8 @@ def project_pointcloud_save_depth(udata, urec, points, shot_id, max_sz): rgb, sm = depth_colormap(depth_image) plt.imshow(rgb) small_colorbar(plt.gca(), mappable=sm) - filepath = Path(udata.data_path) / "plot_depthmaps" / "{}.png".format(shot_id) + filepath = Path(udata.data_path) / "plot_depthmaps" / \ + "{}.png".format(shot_id) filepath.parent.mkdir(exist_ok=True, parents=True) plt.savefig(filepath, dpi=300) plt.close(fig) @@ -511,7 +539,8 @@ def main(): parser = argparse.ArgumentParser( description="Convert COLMAP database to OpenSfM dataset" ) - parser.add_argument("database", help="path to the database to be processed") + parser.add_argument( + "database", help="path to the database to be processed") parser.add_argument("images", help="path to the images") args = parser.parse_args() logger.info(f"Converting {args.database} to COLMAP format") @@ -523,14 +552,16 @@ def main(): export_folder.mkdir(exist_ok=True) images_path = export_folder / "images" if not images_path.exists(): - os.symlink(os.path.abspath(args.images), images_path, target_is_directory=True) + os.symlink(os.path.abspath(args.images), + images_path, target_is_directory=True) # Copy the config if this is an colmap export of an opensfm export if ( p_db.parent.name == "colmap_export" and not (export_folder / "config.yaml").exists() ): - os.symlink(p_db.parent.parent / "config.yaml", export_folder / "config.yaml") + os.symlink(p_db.parent.parent / "config.yaml", + export_folder / "config.yaml") data = dataset.DataSet(export_folder) db = sqlite3.connect(p_db.as_posix()) @@ -583,12 +614,14 @@ def main(): ) else: logger.info( - "Not importing dense reconstruction: Didn't find {}".format(path_ply) + "Not importing dense reconstruction: Didn't find {}".format( + path_ply) ) else: logger.info( - "Didn't find some of the reconstruction files at {}".format(p_db.parent) + "Didn't find some of the reconstruction files at {}".format( + p_db.parent) ) db.close() diff --git a/bin/migrate_undistort.sh b/bin/migrate_undistort.sh index 170d602a4..c17b4bf97 100755 --- a/bin/migrate_undistort.sh +++ b/bin/migrate_undistort.sh @@ -2,16 +2,16 @@ # Migrate dataset to the new undistort folder structure. -if [ $# -le 0 ] +if [ $# -le 0 ] then - echo "Migrate dataset to the new undistort folder structure." + echo "Migrate dataset to the new undistort folder structure." echo - echo "Usage:" + echo "Usage:" echo echo " $0 dataset" echo - exit 1 -fi + exit 1 +fi cd $1 diff --git a/bin/opensfm b/bin/opensfm index 8e2aec956..9cc574aa4 100755 --- a/bin/opensfm +++ b/bin/opensfm @@ -3,10 +3,15 @@ set -e DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) -if [ -e "$(command -v python3)" ]; then +if [ -n "$CONDA_PREFIX" ] && [ -x "$CONDA_PREFIX/bin/python" ]; then + PYTHON="$CONDA_PREFIX/bin/python" +elif [ -x "$(command -v python)" ]; then + PYTHON=python +elif [ -x "$(command -v python3)" ]; then PYTHON=python3 else - PYTHON=python + echo "Could not find a Python interpreter." >&2 + exit 1 fi "$PYTHON" "$DIR"/opensfm_main.py "$@" diff --git a/bin/opensfm_main.py b/bin/opensfm_main.py index 31249e129..b91abcf9c 100755 --- a/bin/opensfm_main.py +++ b/bin/opensfm_main.py @@ -1,5 +1,6 @@ +# pyre-strict import sys -from os.path import abspath, join, dirname +from os.path import abspath, dirname, join sys.path.insert(0, abspath(join(dirname(__file__), ".."))) @@ -21,7 +22,13 @@ def create_default_dataset_context( dataset.clean_up() -if __name__ == "__main__": +def main() -> None: commands.command_runner( - commands.opensfm_commands, create_default_dataset_context, dataset_choices=["opensfm"] + commands.opensfm_commands, + create_default_dataset_context, + dataset_choices=["opensfm"], ) + + +if __name__ == "__main__": + main() # pragma: no cover diff --git a/bin/plot_gcp.py b/bin/plot_gcp.py index 64baa2ee8..ca28ef63d 100644 --- a/bin/plot_gcp.py +++ b/bin/plot_gcp.py @@ -1,41 +1,39 @@ -"""Plot image crops around GCPs. -""" +"""Plot image crops around GCPs.""" import argparse import logging from typing import List -import numpy as np import matplotlib.pyplot as plt import matplotlib matplotlib.use('TkAgg') +import numpy as np + import opensfm.reconstruction as orec -from opensfm import features -from opensfm import io -from opensfm import dataset -from opensfm import pymap -from opensfm import types +from opensfm import dataset, features, io, multiview, pymap, types logger = logging.getLogger(__name__) def parse_args(): - parser = argparse.ArgumentParser( - description=__doc__) + parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( - 'dataset', - help='dataset', + "dataset", + help="dataset", ) return parser.parse_args() def pix_coords(x, image): return features.denormalized_image_coordinates( - np.array([[x[0], x[1]]]), image.shape[1], image.shape[0])[0] + np.array([[x[0], x[1]]]), image.shape[1], image.shape[0] + )[0] -def gcp_to_ply(gcps: List[pymap.GroundControlPoint], reconstruction: types.Reconstruction): +def gcp_to_ply( + gcps: List[pymap.GroundControlPoint], reconstruction: types.Reconstruction +): """Export GCP position as a PLY string.""" vertices = [] @@ -43,16 +41,19 @@ def gcp_to_ply(gcps: List[pymap.GroundControlPoint], reconstruction: types.Recon if gcp.lla: p = reconstruction.reference.to_topocentric(*gcp.lla_vec) else: - p = orec.triangulate_gcp(gcp, reconstruction.shots) + result = multiview.triangulate_gcp(gcp, reconstruction.shots) + p = result[0] if result is not None else None if p is None: - logger.warning("Could not compute the 3D position of GCP '{}'" - .format(gcp.id)) + logger.warning( + "Could not compute the 3D position of GCP '{}'".format(gcp.id) + ) continue c = 255, 0, 0 s = "{} {} {} {} {} {}".format( - p[0], p[1], p[2], int(c[0]), int(c[1]), int(c[2])) + p.value[0], p.value[1], p.value[2], int(c[0]), int(c[1]), int(c[2]) + ) vertices.append(s) header = [ @@ -68,20 +69,20 @@ def gcp_to_ply(gcps: List[pymap.GroundControlPoint], reconstruction: types.Recon "end_header", ] - return '\n'.join(header + vertices + ['']) + return "\n".join(header + vertices + [""]) def main(): args = parse_args() logging.basicConfig( - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - level=logging.DEBUG) + format="%(asctime)s %(levelname)s %(name)s: %(message)s", level=logging.DEBUG + ) data = dataset.DataSet(args.dataset) reconstruction = data.load_reconstruction()[0] gcps = data.load_ground_control_points() - with io.open_wt(data.data_path + '/gcp.ply') as fout: + with io.open_wt(data.data_path + "/gcp.ply") as fout: fout.write(gcp_to_ply(gcps, reconstruction)) for gcp in gcps: @@ -90,11 +91,13 @@ def main(): if gcp.lla: coordinates = reconstruction.reference.to_topocentric(*gcp.lla_vec) else: - coordinates = orec.triangulate_gcp(gcp, reconstruction.shots) + result = multiview.triangulate_gcp(gcp, reconstruction.shots) + coordinates = result[0] if result is not None else None if coordinates is None: - logger.warning("Could not compute the 3D position of GCP '{}'" - .format(gcp.id)) + logger.warning( + "Could not compute the 3D position of GCP '{}'".format(gcp.id) + ) continue for i, observation in enumerate(gcp.observations): @@ -115,5 +118,5 @@ def main(): plt.show() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/bin/plot_inliers b/bin/plot_inliers index b970318c7..19a808c9e 100755 --- a/bin/plot_inliers +++ b/bin/plot_inliers @@ -128,7 +128,7 @@ def create_subplot(figure, rows, columns, index, title, x_lim, y_lim, font_size= subplot.set_aspect(aspect) if grid: - pl.grid(b=True, which='major', color='0.3') + pl.grid(visible=True, which='major', color='0.3') return subplot @@ -231,21 +231,21 @@ def reprojection_errors(reprojections, observations, scale): return errors, mean, std, min_norm, max_norm, corr -def triangulate_tracks(tracks, reconstruction, graph, min_ray_angle): +def triangulate_tracks(tracks, reconstruction, tracks_manager, min_ray_angle, min_depth): """ Triangulates a list of tracks. :param tracks: The array of tracks. :param reconstruction: The reconstruction. - :param: graph, The tracks graph. + :param: tracks_manager, The tracks manager. :param: min_ray_angle: The minimum ray angle difference for a triangulation to be considered valid. :return: An array of booleans determining if each track was successfully triangulated or not. """ succeeded = [] - triangulator = reconstruct.TrackTriangulator(reconstruction, reconstruct.TrackHandlerTrackManager(graph, reconstruction) + triangulator = reconstruct.TrackTriangulator(reconstruction, reconstruct.TrackHandlerTrackManager(tracks_manager, reconstruction)) for track in tracks: # Triangulate with 1 as reprojection threshold to avoid excluding tracks because of error. - triangulator.triangulate(str(track), 1, min_ray_angle) + triangulator.triangulate(str(track), 1, min_ray_angle, min_depth, iterations=10) succeeded.append(True) if str(track) in reconstruction.points else succeeded.append(False) return np.array(succeeded) if len(succeeded) else np.empty((0,), bool) @@ -319,8 +319,8 @@ def load_common_tracks(im1, im2, tracks_manager): :param tracks_manager: The track manager. :return: An array with rows containing a track id and the corresponding feature id for the first and second image. """ - t1 = tracks_manager.get_shot_observations(im1).items() - t2 = tracks_manager.get_shot_observations(im2).items() + t1 = tracks_manager.get_shot_observations(im1) + t2 = tracks_manager.get_shot_observations(im2) tc, p1, p2 = tracking.common_tracks(tracks_manager, im1, im2) common_tracks = [] @@ -417,7 +417,7 @@ def create_matches_figure(im1, im2, data, save_figs=False, single_column=False): p1 = features_data1.points p2 = features_data2.points - symmetric_matches = matching.match_brute_force_symmetric(f1, f2, data.config) + symmetric_matches = matching.match_brute_force_symmetric(p1, p2, data.config) symmetric_matches = np.array(symmetric_matches) if symmetric_matches.shape[0] < 8: @@ -519,7 +519,7 @@ def plot_opencv_find_homography(fw, ip, index, tracks, track_points1, track_poin threshold, pixels1, pixels2 = thresholds(ip.im1_array, ip.im2_array, 'homography_threshold', 0.004, data) H, inliers = cv2.findHomography(track_points1, track_points2, cv2.RANSAC, threshold) - inliers = np.array(np.squeeze(inliers), np.bool) + inliers = np.array(np.squeeze(inliers), bool) inliers1 = track_points1[inliers, :] inliers2 = track_points2[inliers, :] @@ -554,7 +554,7 @@ def plot_two_view_reconstruction(fw, ip, index, track_points1, track_points2, da threshold, pixels1, pixels2 = thresholds(ip.im1_array, ip.im2_array, 'five_point_algo_threshold', 0.006, data) iterations = data.config['five_point_refine_rec_iterations'] - R, t, inliers = reconstruct.two_view_reconstruction(track_points1, track_points2, camera1, camera2, threshold, iterations) + R, t, inliers, _ = reconstruct.two_view_reconstruction_general(track_points1, track_points2, camera1, camera2, threshold, iterations) inliers1 = track_points1[inliers, :] inliers2 = track_points2[inliers, :] @@ -573,7 +573,7 @@ def plot_two_view_reconstruction(fw, ip, index, track_points1, track_points2, da plot_matches(subplot, ip.im1_array, ip.im2_array, outliers1, outliers2, 'r', 'om') -def plot_bootstrapped_reconstruction(fw, ip, index, p1, p2, robust_tracks, linked_tracks, graph, data): +def plot_bootstrapped_reconstruction(fw, ip, index, p1, p2, robust_tracks, linked_tracks, tracks_manager, data): """ Plot successfully reconstructed and failed 3D points by bootstrapping a reconstruction. :param fw: Figure wrapper. @@ -583,13 +583,13 @@ def plot_bootstrapped_reconstruction(fw, ip, index, p1, p2, robust_tracks, linke :param p2: Feature points for the second image. :param robust_tracks: Tracks corresponding to robust matches. :param linked_tracks: Common tracks linked by other image correspondences. - :param graph: Tracks graph. + :param tracks_manager: The tracks manager. :param data: Data set. """ print_reset = redirect_print() - _, pm1, pm2 = tracking.common_tracks(graph, ip.im1, ip.im2) - reconstruction, _, _ = reconstruct.bootstrap_reconstruction(data, graph, ip.im1, ip.im2, pm1, pm2) + _, pm1, pm2 = tracking.common_tracks(tracks_manager, ip.im1, ip.im2) + reconstruction, _ = reconstruct.bootstrap_reconstruction(data, tracks_manager, ip.im1, ip.im2, pm1, pm2) reset_print(print_reset) threshold, pixels1, pixels2 = thresholds(ip.im1_array, ip.im2_array, 'triangulation_threshold', 0.004, data) @@ -667,7 +667,7 @@ def create_tracks_figure(im1, im2, data, save_figs=False, single_column=False): plot_common_tracks(fw, ip, 1, p1, p2, tracks, robust_tracks, linked_tracks, robust_matches) plot_opencv_find_homography(fw, ip, 2, tracks, track_points1, track_points2, data) plot_two_view_reconstruction(fw, ip, 3, track_points1, track_points2, data) - plot_bootstrapped_reconstruction(fw, ip, 4, p1, p2, robust_tracks, linked_tracks, graph, data) + plot_bootstrapped_reconstruction(fw, ip, 4, p1, p2, robust_tracks, linked_tracks, tracks_manager, data) display_figure(fig, save_figs, data, '{0}_{1}_{2}_tracks.jpg'.format(im1, im2, data.feature_type())) @@ -704,7 +704,7 @@ def plot_reconstructed_tracks(fw, ip, index, p1, p2, tracks, rec_tracks, non_rec plot_matches(subplot, ip.im1_array, ip.im2_array, non_rec_track_points1, non_rec_track_points2, 'r', 'om') -def plot_reprojected_tracks(fw, ip, index, p1, p2, tracks, rec_tracks, non_rec_tracks, reconstruction, graph, data): +def plot_reprojected_tracks(fw, ip, index, p1, p2, tracks, rec_tracks, non_rec_tracks, reconstruction, tracks_manager, data): """ Reprojects tracks included in and excluded from reconstruction and plots reprojections on top of observations. :param fw: Figure wrapper. @@ -716,7 +716,7 @@ def plot_reprojected_tracks(fw, ip, index, p1, p2, tracks, rec_tracks, non_rec_t :param rec_tracks: Common tracks included in the reconstruction. :param non_rec_tracks: Common tracks not included in the reconstruction. :param reconstruction: Reconstruction. - :param graph: Tracks graph. + :param tracks_manager: The tracks manager. :param data: Data set """ @@ -727,7 +727,8 @@ def plot_reprojected_tracks(fw, ip, index, p1, p2, tracks, rec_tracks, non_rec_t rp2 = reproject_tracks(ip.im2, rec_tracks[:, 0], reconstruction) min_ray_angle = data.config['triangulation_min_ray_angle'] - succeeded = triangulate_tracks(non_rec_tracks[:, 0], reconstruction, graph, min_ray_angle) + min_depth = data.config['triangulation_min_depth'] + succeeded = triangulate_tracks(non_rec_tracks[:, 0], reconstruction, tracks_manager, min_ray_angle, min_depth) reprojected1 = reproject_tracks(ip.im1, non_rec_tracks[succeeded, 0], reconstruction) reprojected2 = reproject_tracks(ip.im2, non_rec_tracks[succeeded, 0], reconstruction) @@ -786,7 +787,7 @@ def create_reconstruction_matches_figure(im1, im2, data, save_figs=False): rec_tracks, non_rec_tracks = reconstruction_tracks(tracks, reconstruction) plot_reconstructed_tracks(fw, ip, 1, p1, p2, tracks, rec_tracks, non_rec_tracks) - plot_reprojected_tracks(fw, ip, 2, p1, p2, tracks, rec_tracks, non_rec_tracks, reconstruction, graph, data) + plot_reprojected_tracks(fw, ip, 2, p1, p2, tracks, rec_tracks, non_rec_tracks, reconstruction, tracks_manager, data) display_figure(fig, save_figs, data, '{0}_{1}_{2}_reconstruction.jpg'.format(im1, im2, data.feature_type())) @@ -833,7 +834,7 @@ def create_complete_reconstruction_figure(im, data, save_figs=False, single_colu plot_points(rec_plot, im_array, rp, '+w') min_ray_angle = data.config['triangulation_min_ray_angle'] - succeeded = triangulate_tracks(non_rec_tracks[:, 0], reconstruction, graph, min_ray_angle) + succeeded = triangulate_tracks(non_rec_tracks[:, 0], reconstruction, tracks_manager, min_ray_angle) reprojected = reproject_tracks(im, non_rec_tracks[succeeded, 0], reconstruction) triangulated_points = p[non_rec_tracks[succeeded, 1]] @@ -887,7 +888,7 @@ def create_reprojection_error_figure(im, data, save_figs=False, single_column=Fa reproj_rec = reproject_tracks(im, rec_tracks[:, 0], reconstruction) min_ray_angle = data.config['triangulation_min_ray_angle'] - succeeded = triangulate_tracks(non_rec_tracks[:, 0], reconstruction, graph, min_ray_angle) + succeeded = triangulate_tracks(non_rec_tracks[:, 0], reconstruction, tracks_manager, min_ray_angle) reproj_non = reproject_tracks(im, non_rec_tracks[succeeded, 0], reconstruction) triang_non = p[non_rec_tracks[succeeded, 1]] @@ -897,7 +898,7 @@ def create_reprojection_error_figure(im, data, save_figs=False, single_column=Fa non_errors, non_mean, non_std, non_min, non_max, non_corr = reprojection_errors(reproj_non, triang_non, scale) triang_thld = data.config['triangulation_threshold'] - bundle_thld = data.config['bundle_outlier_threshold'] + bundle_thld = data.config['bundle_outlier_fixed_threshold'] axis_max = np.max(np.abs(np.vstack((rec_errors, non_errors)))) axis_max = 1.05 * np.max([axis_max, scale * triang_thld, scale * bundle_thld]) diff --git a/bin/plot_matches.py b/bin/plot_matches.py index 94f9d7fbf..ab94b2e94 100755 --- a/bin/plot_matches.py +++ b/bin/plot_matches.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 +# pyre-unsafe + import argparse import os.path from itertools import combinations +from typing import List import matplotlib.cm as cm import matplotlib.pyplot as pl import numpy as np -from opensfm import dataset -from opensfm import features -from opensfm import io from numpy import ndarray -from typing import List +from opensfm import dataset, features, io def plot_matches(im1, im2, p1: ndarray, p2: ndarray) -> None: @@ -113,7 +113,8 @@ def plot_matches_for_images(data, image, images) -> None: pl.show() -if __name__ == "__main__": +def main() -> None: + global args, images parser = argparse.ArgumentParser(description="Plot matches between images") parser.add_argument("dataset", help="path to the dataset to be processed") parser.add_argument("--image", help="show tracks for a specific") @@ -124,12 +125,20 @@ def plot_matches_for_images(data, image, images) -> None: parser.add_argument( "--save_figs", help="save figures instead of showing them", action="store_true" ) - args: argparse.Namespace = parser.parse_args() + args = parser.parse_args() data = dataset.DataSet(args.dataset) - images: List[str] = data.images() + images = data.images() if args.graph: plot_graph(data) else: plot_matches_for_images(data, args.image, args.images) + + +args: argparse.Namespace +images: List[str] + + +if __name__ == "__main__": + main() # pragma: no cover diff --git a/bin/train_dif_projection.py b/bin/train_dif_projection.py new file mode 100755 index 000000000..e377c3080 --- /dev/null +++ b/bin/train_dif_projection.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# pyre-strict +import argparse +import glob +import json +import logging +import os +import sys +from typing import Any, Dict, List, Tuple + +import numpy as np + +# Add parent directory to sys.path so opensfm can be imported +sys.path.insert(0, os.path.abspath( + os.path.join(os.path.dirname(__file__), ".."))) + +from opensfm import context, dataset, log, matching, pairs_selection + +logger: logging.Logger = logging.getLogger("train_dif_projection") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Offline training of DIF binary projection." + ) + parser.add_argument( + "json_file", + help="Path to JSON file listing dataset folders and their associated K.", + ) + parser.add_argument( + "--default-k", + type=int, + default=5, + help="Default K (number of pairs) if not specified in JSON.", + ) + return parser.parse_args() + + +def load_datasets_from_json(json_file: str, default_k: int) -> List[Tuple[str, int]]: + with open(json_file, "r") as f: + config_data = json.load(f) + + datasets_to_process = [] + if isinstance(config_data, list): + for entry in config_data: + if isinstance(entry, dict) and "path" in entry: + datasets_to_process.append( + (entry["path"], entry.get("k", default_k))) + elif isinstance(entry, str): + datasets_to_process.append((entry, default_k)) + elif isinstance(config_data, dict): + k_val = config_data.get("k", default_k) + datasets = config_data.get("datasets", []) + if isinstance(datasets, list): + for d in datasets: + if isinstance(d, dict) and "path" in d: + datasets_to_process.append((d["path"], d.get("k", k_val))) + elif isinstance(d, str): + datasets_to_process.append((d, k_val)) + return datasets_to_process + + +def main() -> None: + args = parse_args() + log.setup() + + datasets_to_process = load_datasets_from_json( + args.json_file, args.default_k) + logger.info(f"Loaded {len(datasets_to_process)} datasets to process.") + + processed_datasets = [] + all_pos_d1 = [] + all_pos_d2 = [] + all_neg_d1 = [] + all_neg_d2 = [] + feature_types = [] + + for path, k in datasets_to_process: + logger.info(f"Processing dataset: {path} with K={k}") + if not os.path.exists(path): + logger.error(f"Dataset path does not exist: {path}") + continue + + try: + data = dataset.DataSet(path) + images = data.images() + if not images: + logger.warning(f"No images found in dataset: {path}") + continue + + exifs = {im: data.load_exif(im) for im in images} + cameras = data.load_camera_models() + + pairs, preport = pairs_selection.match_candidates_from_metadata( + images, + images, + exifs, + data, + data.config, + ) + if not pairs: + logger.warning(f"No candidate pairs found in dataset: {path}") + continue + + n_sample = min(k, len(pairs)) + rng = np.random.RandomState(42) + sample_idx = rng.choice(len(pairs), n_sample, replace=False) + sample_pairs = [pairs[i] for i in sample_idx] + + # Force FLANN matching on CPU for training pairs + training_config = dict(data.config) + training_config["matcher_type"] = "FLANN" + training_config["use_robust_matching"] = True + + training_args = list( + matching.match_arguments( + sample_pairs, data, training_config, cameras, exifs, None, None + ) + ) + processes = data.config.get("processes", 1) + processes = context.processes_that_fit_in_memory(processes, 512) + + logger.info( + f"Matching {len(sample_pairs)} training pairs with FLANN ({processes} processes)" + ) + training_matches = context.parallel_map( + matching.match_unwrap_args, training_args, processes, 2 + ) + + pos_d1, pos_d2, neg_d1, neg_d2 = matching.collect_training_pairs( + data, + training_matches, + training_config, + ) + + if len(pos_d1) == 0: + logger.warning( + f"No positive pairs collected from dataset {path}") + continue + + all_pos_d1.append(pos_d1) + all_pos_d2.append(pos_d2) + all_neg_d1.append(neg_d1) + all_neg_d2.append(neg_d2) + feature_types.append(data.config.get("feature_type", "HAHOG")) + processed_datasets.append(path) + logger.info(f"Successfully collected training pairs for {path}") + + except Exception as e: + logger.exception( + f"Exception raised while processing dataset {path}: {e}") + + if not all_pos_d1: + logger.error( + "No training data was collected from any of the datasets.") + sys.exit(1) + + logger.info("Concatenating descriptors from all datasets...") + pos_d1_total = np.concatenate(all_pos_d1, axis=0) + pos_d2_total = np.concatenate(all_pos_d2, axis=0) + neg_d1_total = np.concatenate(all_neg_d1, axis=0) + neg_d2_total = np.concatenate(all_neg_d2, axis=0) + + # Check for feature type consistency + if len(set(feature_types)) > 1: + logger.warning( + f"Multiple feature types detected: {list(set(feature_types))}. Using {feature_types[0]}." + ) + descriptor_used = feature_types[0] if feature_types else "HAHOG" + + # Train DIF projection + logger.info("Training joint DIF binary projection...") + P, t = matching.train_dif_projection( + pos_d1_total, pos_d2_total, neg_d1_total, neg_d2_total, clear_features_cache=True + ) + + # Save P and t + dif_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "opensfm", "data", "dif") + ) + os.makedirs(dif_dir, exist_ok=True) + + dataset_count = len(processed_datasets) + k_values = [entry[1] + for entry in datasets_to_process if entry[0] in processed_datasets] + if len(set(k_values)) == 1: + K_filename = k_values[0] + else: + K_filename = k_values[0] if k_values else args.default_k + + filename = f"{dataset_count}_{K_filename}_{descriptor_used}.npz" + filepath = os.path.join(dif_dir, filename) + + logger.info(f"Saving P and t under {filepath}") + np.savez(filepath, P=P, t=t) + logger.info("Offline training completed successfully.") + + +if __name__ == "__main__": + main() diff --git a/conda-linux-64.lock b/conda-linux-64.lock new file mode 100644 index 000000000..aac0acdfc --- /dev/null +++ b/conda-linux-64.lock @@ -0,0 +1,260 @@ +# Generated by conda-lock. +# platform: linux-64 +# input_hash: a148a6b522d6b7cd6aa024bacec9c82451e8e21896de74fea8ee606bb878df7f +@EXPLICIT +https://conda.anaconda.org/anaconda/linux-64/_openmp_mutex-5.1-52_gnu.tar.bz2#9fc9aff1a9d85b7d0dd3bad20081f29c +https://conda.anaconda.org/anaconda/linux-64/ca-certificates-2026.5.14-h06a4308_0.tar.bz2#4442d01050991e61de9107b1140d0e3c +https://conda.anaconda.org/conda-forge/linux-64/eigen-abi-5.0.1.80-hf414acd_0.conda#a85c1768af7780d67c6446c17848b76f +https://conda.anaconda.org/anaconda/noarch/font-ttf-dejavu-sans-mono-2.37-hd3eb1b0_0.tar.bz2#801b4743a301a4e11cbd9609d418e101 +https://conda.anaconda.org/anaconda/noarch/font-ttf-inconsolata-2.001-hcb22688_0.tar.bz2#d4a96c0ce14ac06cbd80976fa8439fb7 +https://conda.anaconda.org/anaconda/noarch/font-ttf-source-code-pro-2.030-hd3eb1b0_0.tar.bz2#947240b599a47f2633706c6bdc591a17 +https://conda.anaconda.org/anaconda/noarch/font-ttf-ubuntu-0.83-h8b1ccd4_0.tar.bz2#0b017158a7cd7c1683e00b4ae688dce4 +https://conda.anaconda.org/anaconda/noarch/kernel-headers_linux-64-4.18.0-h3108a97_1.tar.bz2#d316f70a78e922479dcbc4df9d8fee62 +https://conda.anaconda.org/anaconda/linux-64/python_abi-3.10-3_cp310.tar.bz2#30c6ee2a53db622eabc8c62f6ae1ff9b +https://conda.anaconda.org/anaconda/noarch/tzdata-2026b-he532380_0.tar.bz2#148ba61be8a94bb3db370256ffd35ecb +https://conda.anaconda.org/anaconda/noarch/fonts-anaconda-1-h8fa9717_0.tar.bz2#7a445a2d9fbe2a88de8f09e9527f4ce1 +https://conda.anaconda.org/anaconda/linux-64/ld_impl_linux-64-2.44-h9e0c5a2_3.tar.bz2#f966e8da4e4b1aa50ffd96bae411599a +https://conda.anaconda.org/anaconda/linux-64/libgcc-15.2.0-h69a1729_8.tar.bz2#293e5bf121d856a06b2883e278e5694f +https://conda.anaconda.org/anaconda/noarch/libgcc-devel_linux-64-14.3.0-he7458c1_104.tar.bz2#253480ca231bec98bf1a3b8a840c728f +https://conda.anaconda.org/anaconda/linux-64/libgomp-15.2.0-h4751f2c_8.tar.bz2#ced84470d2b558779c35240ff764d878 +https://conda.anaconda.org/anaconda/noarch/libstdcxx-devel_linux-64-14.3.0-he7458c1_104.tar.bz2#2a2ead700804a8787497fc4a4c5e4f37 +https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-22.1.8-h4922eb0_0.conda#7bbfdc5a6eca997d3b0873a575c3e155 +https://conda.anaconda.org/anaconda/noarch/sysroot_linux-64-2.28-h3108a97_1.tar.bz2#c48a82443a7c91f40ad9d65c613779fa +https://conda.anaconda.org/anaconda/linux-64/alsa-lib-1.2.15.3-h47b2149_0.tar.bz2#b1029c5aa763685475b8a1c60cf58e61 +https://conda.anaconda.org/anaconda/linux-64/attr-2.5.2-h47b2149_0.tar.bz2#86fd7576db8185ec021d08e06a1abb73 +https://conda.anaconda.org/anaconda/linux-64/aws-c-common-0.12.6-h47b2149_0.tar.bz2#f0c5d1b1ca48eb122607e0d23be31bc6 +https://conda.anaconda.org/anaconda/linux-64/binutils_impl_linux-64-2.44-h78f17ca_3.tar.bz2#f07d96ef947676fe548f4d20a425c0eb +https://conda.anaconda.org/anaconda/linux-64/dav1d-1.5.3-h3e43c27_0.tar.bz2#0c0e3b52980fc69044292184958b9acb +https://conda.anaconda.org/anaconda/noarch/fonts-conda-ecosystem-1-hd3eb1b0_0.tar.bz2#81135874e3942ccc48b79c12ec094849 +https://conda.anaconda.org/anaconda/linux-64/fribidi-1.0.16-h10f3c28_1.tar.bz2#6a0437d7562a7ccdf92cb31135c1c853 +https://conda.anaconda.org/anaconda/linux-64/gstreamer-orc-0.4.42-h59a7e29_0.tar.bz2#ad06a7c07c4256276fc0cfca2e5196bf +https://conda.anaconda.org/anaconda/linux-64/jansson-2.15.0-hbcba0ee_0.tar.bz2#b9c6983c9b94a491b68c7bc77f46ec2b +https://conda.anaconda.org/anaconda/linux-64/lame-3.100-hbd0596d_1.tar.bz2#a2018620b3a8d453d48a292255cc17ba +https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/anaconda/linux-64/libgcc-ng-15.2.0-h166f726_8.tar.bz2#c015bc2f069a4a753dce19576f08583c +https://conda.anaconda.org/anaconda/linux-64/libgfortran5-15.2.0-hc633d37_8.tar.bz2#b14d5d857a3873e7e404fef4c279f058 +https://conda.anaconda.org/anaconda/linux-64/libiconv-1.18-h75a1612_0.tar.bz2#52d1e5b216b38891e10160d12a428666 +https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda#6178c6f2fb254558238ef4e6c56fb782 +https://conda.anaconda.org/anaconda/linux-64/libltdl-2.6.0-h47b2149_0.tar.bz2#4ae1e2ceafc7c95aa6343588d261e09d +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda#d864d34357c3b65a4b731f78c0801dc4 +https://conda.anaconda.org/anaconda/linux-64/libopus-1.6.1-h9f10d21_0.tar.bz2#d33910f3d55ed6bd600fa7d2b1596885 +https://conda.anaconda.org/anaconda/linux-64/libstdcxx-15.2.0-h39759b7_8.tar.bz2#1a2f551d06f9919990adcf89a7a113b9 +https://conda.anaconda.org/anaconda/linux-64/libunistring-1.4.2-h34b0ebb_0.tar.bz2#9ec2c8786cd91aa96b26235fe06836d0 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda#01bb81d12c957de066ea7362007df642 +https://conda.anaconda.org/anaconda/linux-64/libuv-1.52.0-heb5a705_0.tar.bz2#e89694bae69d33d1abb79e81a9570684 +https://conda.anaconda.org/anaconda/linux-64/libwebp-base-1.6.0-hb7bb969_0.tar.bz2#a1931ee4b137f6166fb99e5787bc0895 +https://conda.anaconda.org/anaconda/linux-64/libxcrypt-4.5.2-h47b2149_0.tar.bz2#1c1cc8c03bfb679d32af20bba07d57ed +https://conda.anaconda.org/anaconda/linux-64/libzlib-1.3.2-h47b2149_0.tar.bz2#e92f60b88461c1adeb1d1bfcc1fea383 +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/anaconda/linux-64/openssl-3.5.7-h1b28b03_0.tar.bz2#47880c02d76e9bc45d8306bbf9f85da5 +https://conda.anaconda.org/anaconda/linux-64/perl-5.42.2-2_h47b2149_perl5.tar.bz2#efefce337ce9b0888b1610c6847312d7 +https://conda.anaconda.org/anaconda/linux-64/soxr-0.1.3-h6d1b9b4_1.tar.bz2#b14f8ddae1092766e29ea10b8adb1c9a +https://conda.anaconda.org/anaconda/linux-64/aom-3.13.2-h664349e_0.tar.bz2#8a0554f5de469c73fd8674c7b419826f +https://conda.anaconda.org/anaconda/linux-64/aws-c-cal-0.9.13-h1b28b03_0.tar.bz2#0afd25175656c8b53138289657ca988b +https://conda.anaconda.org/anaconda/linux-64/aws-c-compression-0.3.2-h47b2149_0.tar.bz2#d6c939e05a83a781adabcfc8526e2454 +https://conda.anaconda.org/anaconda/linux-64/aws-c-sdkutils-0.2.4-h47b2149_2.tar.bz2#d6863bedba5d8216fb621a56586e9fc9 +https://conda.anaconda.org/anaconda/linux-64/aws-checksums-0.2.10-h47b2149_0.tar.bz2#796a01d19a6946b211d0598c1516700c +https://conda.anaconda.org/anaconda/linux-64/binutils-2.44-hc03a8fd_3.tar.bz2#c6283f13a851371f128249b5681c823d +https://conda.anaconda.org/anaconda/linux-64/binutils_linux-64-2.44-hc03a8fd_3.tar.bz2#3d2af493ca2d890efcd0c6a41d9e3e96 +https://conda.anaconda.org/anaconda/linux-64/bzip2-1.0.8-h5eee18b_6.tar.bz2#f5058c8d5b2994b7334f18e022105a14 +https://conda.anaconda.org/anaconda/linux-64/c-ares-1.34.6-hd44998d_0.tar.bz2#3772d25d65b6266cc183ce5f8a8cad00 +https://conda.anaconda.org/conda-forge/linux-64/fast_float-8.1.0-hb700be7_0.conda#e76f27bc887647e4164cfbaba05f3a86 +https://conda.anaconda.org/anaconda/linux-64/fftw-3.3.10-hbd630f4_0.tar.bz2#a40951cc1d01bb3d44d5d7177ab2d7fa +https://conda.anaconda.org/conda-forge/linux-64/geos-3.14.1-h480dda7_0.conda#4d4efd0645cd556fab54617c4ad477ef +https://conda.anaconda.org/anaconda/linux-64/gettext-tools-0.25.1-h6a67909_0.tar.bz2#982e99faed9dbdf70578e453494c68e0 +https://conda.anaconda.org/anaconda/linux-64/gflags-2.3.0-h861b1fb_0.tar.bz2#e9d38e92aef00995352b100343267f27 +https://conda.anaconda.org/anaconda/linux-64/giflib-5.2.2-h5eee18b_0.tar.bz2#dd71853d7c8a1ca649c51956c07d32ba +https://conda.anaconda.org/anaconda/linux-64/graphite2-1.3.15-h9ba177b_0.tar.bz2#68a7b9be8a86e813b3e1fcd8897d7391 +https://conda.anaconda.org/anaconda/linux-64/icu-78.3-h84d19a5_0.tar.bz2#a167c24e22b2e2925fb6ab7a0b559514 +https://conda.anaconda.org/anaconda/linux-64/intel-gmmlib-22.9.0-h24d9097_0.tar.bz2#837fd11fc76ab94cea5bbde4bf967110 +https://conda.anaconda.org/anaconda/linux-64/jack-1.9.22-h8fd242f_0.tar.bz2#904cda0db77e75233cd5f22af671e3df +https://conda.anaconda.org/anaconda/linux-64/json-c-0.18-hee96239_0.tar.bz2#b6fa680f8d1b59673291706af26f74fc +https://conda.anaconda.org/anaconda/linux-64/lerc-4.1.0-h7354ed3_2.tar.bz2#5d83c62feacd8835a6028bb8c2801171 +https://conda.anaconda.org/anaconda/linux-64/libabseil-20260107.0-cxx17_h6199ee8_0.tar.bz2#63b5d2c2c8b10c24899a305ed57cd677 +https://conda.anaconda.org/anaconda/linux-64/libasprintf-0.25.1-hf2ab22a_0.tar.bz2#7ec77d8498e688bacc9e59a9e3a2034e +https://conda.anaconda.org/anaconda/linux-64/libbrotlicommon-1.2.0-h32cd6e7_0.tar.bz2#dadaee8753a9b5bdf56a8105285e6f1b +https://conda.anaconda.org/anaconda/linux-64/libcap-2.78-h2f14ded_0.tar.bz2#f48255ec8905eaf73685c2c854fa3b91 +https://conda.anaconda.org/anaconda/linux-64/libev-4.33-h7f8727e_1.tar.bz2#eeca774c6154c49340dd403b1928d5e9 +https://conda.anaconda.org/anaconda/linux-64/libexpat-2.8.1-h7354ed3_1.tar.bz2#151a5b2061d70a315fe041151296a733 +https://conda.anaconda.org/anaconda/linux-64/libgettextpo-0.25.1-hf2ab22a_0.tar.bz2#719e9a86f5375ca33b9413909523d3a7 +https://conda.anaconda.org/anaconda/linux-64/libgfortran-15.2.0-h166f726_8.tar.bz2#851e04cfd1dba048da53c9cb738ae564 +https://conda.anaconda.org/anaconda/linux-64/libglvnd-1.7.0-h5eee18b_2.tar.bz2#0df5b4e585769de6428d3f0b1164d316 +https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h10be129_0.conda#3a9428b74c403c71048104d38437b48c +https://conda.anaconda.org/conda-forge/linux-64/liblzma-devel-5.8.3-hb03c661_0.conda#55c20edec8e90c4703787acaade60808 +https://conda.anaconda.org/anaconda/linux-64/libogg-1.3.5-h27cfd23_1.tar.bz2#48ff183e45bf81c02fa5f9ce1ad669dc +https://conda.anaconda.org/anaconda/linux-64/libopenjpeg-2.5.4-hee96239_1.tar.bz2#bd0e71c7471ad7d1f0cada25c826df93 +https://conda.anaconda.org/anaconda/linux-64/libpciaccess-0.18-h5eee18b_0.tar.bz2#3243f3efa6ca719ffe3e94c80020e989 +https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda#eba48a68a1a2b9d3c0d9511548db85db +https://conda.anaconda.org/anaconda/linux-64/libsanitizer-14.3.0-hd4faa28_4.tar.bz2#1ef24ecc853c61380e07312060dd02d0 +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.2-h0c1763c_0.conda#062b0ac602fb0adf250e3dfa86f221c4 +https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda#eecce068c7e4eddeb169591baac20ac4 +https://conda.anaconda.org/anaconda/linux-64/libstdcxx-ng-15.2.0-hc03a8fd_8.tar.bz2#94624cbe792490ec721a0c1dd8103c08 +https://conda.anaconda.org/anaconda/linux-64/libvpx-1.16.0-h4b463fa_0.tar.bz2#23d759c00dacc9a30c40ca4349ca1df7 +https://conda.anaconda.org/anaconda/linux-64/lmdb-0.9.31-hb25bd0a_0.tar.bz2#d2fb8eb98fdc8b33662613602305fea2 +https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda#9de5350a85c4a20c685259b889aa6393 +https://conda.anaconda.org/anaconda/linux-64/lzo-2.10-h7b6447c_2.tar.bz2#2c4fe397d387680272e4ecbf604cd9e8 +https://conda.anaconda.org/anaconda/linux-64/make-4.2.1-h1bed415_1.tar.bz2#274e1eb00ae0a8c18557a5ce7e015e1b +https://conda.anaconda.org/anaconda/linux-64/mpg123-1.33.4-h7354ed3_0.tar.bz2#c030d9ec3981dff254fdc9313f6919a6 +https://conda.anaconda.org/conda-forge/linux-64/muparser-2.3.5-h5888daf_0.conda#ab3e3db511033340e75e7002e80ce8c0 +https://conda.anaconda.org/anaconda/linux-64/ninja-base-1.13.1-h0f57076_0.tar.bz2#8366e4446a8738208418d1990b12e7e3 +https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda#c930c8052d780caa41216af7de472226 +https://conda.anaconda.org/anaconda/linux-64/openh264-2.6.0-ha05cbb4_1.tar.bz2#972ef896a2f4369a753e4369d74b1baa +https://conda.anaconda.org/anaconda/linux-64/pthread-stubs-0.3-h0ce48e5_1.tar.bz2#de7d258a4efcd8a63df73099c8e5d954 +https://conda.anaconda.org/anaconda/linux-64/readline-8.3-hc2a1206_0.tar.bz2#7ae5a77a62d93f38e777a71b56c396cf +https://conda.anaconda.org/anaconda/linux-64/rhash-1.4.6-hd199180_1.tar.bz2#5fc6c20d05fec84d7763086e4b4c6de8 +https://conda.anaconda.org/anaconda/linux-64/s2n-1.6.2-h02aa81b_0.tar.bz2#b199146a2b5287172464d0959bbac85a +https://conda.anaconda.org/anaconda/linux-64/spirv-tools-2026.1-h24d9097_0.tar.bz2#2300f0f42a231355f3d1d6812b4b612b +https://conda.anaconda.org/anaconda/linux-64/svt-av1-3.1.2-h7354ed3_0.tar.bz2#716c2e317bc518dabb8af6bfd2b647bc +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab +https://conda.anaconda.org/anaconda/linux-64/uriparser-0.9.8-h451ca9b_0.tar.bz2#3a0a6a531ee44a7688c5389b30d43acf +https://conda.anaconda.org/anaconda/linux-64/xorg-libxau-1.0.12-h9b100fa_0.tar.bz2#962cde7b3951cf7244e2ed2d059900a6 +https://conda.anaconda.org/anaconda/linux-64/xorg-libxdmcp-1.1.5-h9b100fa_0.tar.bz2#06a53975cfb6a6c46f7c60a0860a546c +https://conda.anaconda.org/anaconda/linux-64/xorg-xorgproto-2024.1-h5eee18b_1.tar.bz2#47fbf3dec64342c6034a77c9b5a02155 +https://conda.anaconda.org/conda-forge/linux-64/xz-gpl-tools-5.8.3-ha02ee65_0.conda#8f5e2c6726c1339287a3c76a2c138ac7 +https://conda.anaconda.org/conda-forge/linux-64/xz-tools-5.8.3-hb03c661_0.conda#b62b615caa60812640f24db3a8d0fc87 +https://conda.anaconda.org/anaconda/linux-64/zlib-1.3.2-h47b2149_0.tar.bz2#c9e7661c578bc42c239858035ddf4686 +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/anaconda/linux-64/aws-c-io-0.26.3-h1b29dbc_0.tar.bz2#65b704779ef944ac8a3015d77b1e944a +https://conda.anaconda.org/anaconda/linux-64/eigen-5.0.1-hf04f85b_0.tar.bz2#3288237275cc191e4be00a1430f58bff +https://conda.anaconda.org/anaconda/linux-64/expat-2.8.1-h7354ed3_1.tar.bz2#734dd289ab765fae72b9295d21b4a61e +https://conda.anaconda.org/anaconda/linux-64/freetype-2.14.1-hf5b9546_0.tar.bz2#8b8266e3fe224f7615ed9e1a4df4116d +https://conda.anaconda.org/anaconda/linux-64/gcc_impl_linux-64-14.3.0-h4943218_4.tar.bz2#6e292ab59db9a8e906b5dec000b96ba2 +https://conda.anaconda.org/anaconda/linux-64/glog-0.7.1-h485759d_0.tar.bz2#5bcda8f8ed7f9f12b6fe2f4f7166a314 +https://conda.anaconda.org/anaconda/linux-64/glslang-15.3.0-h24f8bec_1.tar.bz2#81488b0dbea9dc98a93dad284af188c6 +https://conda.anaconda.org/anaconda/linux-64/gmp-6.3.0-h6a678d5_0.tar.bz2#35fd28cd000de8455792e65efac72fcf +https://conda.anaconda.org/anaconda/linux-64/gtest-1.17.0-h5f83a47_0.tar.bz2#b505aed99ae2c05a6e5efb9796b54f28 +https://conda.anaconda.org/conda-forge/linux-64/laszip-3.4.3-h9c3ff4c_2.tar.bz2#f99abefd6976c4b1ca3c976b1a86d34c +https://conda.anaconda.org/anaconda/linux-64/libasprintf-devel-0.25.1-hf2ab22a_0.tar.bz2#52439a036d71c3d17210edbe0da767ae +https://conda.anaconda.org/anaconda/linux-64/libavif-1.3.0-h2b90b00_1.tar.bz2#88464e4b9a447a72ea9deb236f27b092 +https://conda.anaconda.org/anaconda/linux-64/libbrotlidec-1.2.0-ha2c5f68_0.tar.bz2#8acfc105febd845a0991c1fce21d2d03 +https://conda.anaconda.org/anaconda/linux-64/libbrotlienc-1.2.0-h2e96acb_0.tar.bz2#90ab7ff53b3151044b480a216cc1d47a +https://conda.anaconda.org/anaconda/linux-64/libdrm-2.4.124-h5eee18b_0.tar.bz2#6dc09b32900c56f2a0c82fdc6bf48e1c +https://conda.anaconda.org/anaconda/linux-64/libegl-1.7.0-h5eee18b_2.tar.bz2#4d3c0e3a219bee6d250216542b69245e +https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda#8e7251989bca326a28f4a5ffbd74557a +https://conda.anaconda.org/anaconda/linux-64/libgettextpo-devel-0.25.1-hf2ab22a_0.tar.bz2#388a03fe90b3b7cdc74748030c9f6b9b +https://conda.anaconda.org/conda-forge/linux-64/libkml-1.3.0-haa4a5bd_1023.conda#953b7cca897e21215302dbfe2af5cd0c +https://conda.anaconda.org/anaconda/linux-64/libkrb5-1.22.2-hbd13c29_0.tar.bz2#590f1b619c115b7738e86c9dad2bce0a +https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda#2d3278b721e40468295ca755c3b84070 +https://conda.anaconda.org/anaconda/linux-64/libprotobuf-6.33.5-h4435b4a_0.tar.bz2#bb7e0eea6aacc7fc0d63f3faa9e065e5 +https://conda.anaconda.org/conda-forge/linux-64/librttopo-1.1.0-h46dd2a8_20.conda#df81fd57eacf341588d728c97920e86d +https://conda.anaconda.org/conda-forge/linux-64/libsuitesparseconfig-7.10.1-h901830b_7100101.conda#57ae1dd979da7aa88a9b38bfa2e1d6b2 +https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 +https://conda.anaconda.org/anaconda/linux-64/libunwind-1.8.3-h9c1abfd_0.tar.bz2#9c2bed8a0d72288e10e0efdf7fcc4591 +https://conda.anaconda.org/anaconda/linux-64/libvorbis-1.3.7-h536e056_1.tar.bz2#eb5ca8debe77e3a6c64ca0fafb8d1e3e +https://conda.anaconda.org/anaconda/linux-64/libxcb-1.17.0-h9b100fa_0.tar.bz2#623e472cf0c02599f01c49879dab1bb6 +https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.14.6-hca6bf5a_3.conda#d6a4f5f909f451af83ca210308147887 +https://conda.anaconda.org/anaconda/linux-64/metis-5.1.0-hf484d3e_4.tar.bz2#0f47d15d9f839362c6b2a755cb10d93b +https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.4-hb03c661_1.conda#c2871ba95727fd1382c05db66048b64c +https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda#7a3bff861a6583f1889021facefc08b1 +https://conda.anaconda.org/anaconda/linux-64/pixman-0.46.4-h7934f7d_0.tar.bz2#a59f97b6d67c0b41f08a8ed649f6d8a1 +https://conda.anaconda.org/conda-forge/linux-64/python-3.10.20-h267e890_1_cpython.conda#c5eace1c2d8dae0bb08c094617ea8cc7 +https://conda.anaconda.org/anaconda/linux-64/snappy-1.2.1-h6a678d5_0.tar.bz2#25ac00d957eda056675f8fa3eafdf6df +https://conda.anaconda.org/anaconda/linux-64/sqlite-3.53.2-h795bf6d_0.tar.bz2#2e2556cb78dbb15c97e5b9088fccacbc +https://conda.anaconda.org/anaconda/linux-64/xerces-c-3.3.0-h8929472_1.tar.bz2#42223b742826d5b6f0dee86ba85bf4b6 +https://conda.anaconda.org/anaconda/linux-64/xorg-libice-1.1.2-h9b100fa_0.tar.bz2#f1a404b305a38ca8f388e41861dd7a01 +https://conda.anaconda.org/conda-forge/linux-64/xz-5.8.3-ha02ee65_0.conda#6a1b6af49a334e4e06b9f103367762bf +https://conda.anaconda.org/anaconda/linux-64/aws-c-http-0.10.13-h47b2149_0.tar.bz2#bb8cb920226f3951a67871d08c4e10fc +https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda#2c2fae981fd2afd00812c92ac47d023d +https://conda.anaconda.org/anaconda/linux-64/conda-gcc-specs-14.3.0-h214dd08_4.tar.bz2#d581db813af6459755f3b80b38e7c7fa +https://conda.anaconda.org/anaconda/linux-64/gcc_linux-64-14.3.0-hda73cce_14.tar.bz2#774caa87847ed8f55027e91849d2f9cf +https://conda.anaconda.org/anaconda/linux-64/gettext-0.25.1-h92eb808_0.tar.bz2#4f015e8d575ea6735cf51c8ed71d8b8b +https://conda.anaconda.org/conda-forge/linux-64/gperftools-2.18.1-h65a8314_0.conda#580e68c39529cb81ac43948c07ea8a6c +https://conda.anaconda.org/anaconda/linux-64/gxx_impl_linux-64-14.3.0-he634eba_4.tar.bz2#77e2342620626f2d330991b83ca90590 +https://conda.anaconda.org/anaconda/linux-64/lcms2-2.19.1-h425df66_0.tar.bz2#e79867d112e4afad5f41a4f0633e217c +https://conda.anaconda.org/conda-forge/linux-64/libamd-3.3.3-h456b2da_7100101.conda#a067596d679bcde85375143e7c374738 +https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-8_h4a7cf45_openblas.conda#00fc660ab1b2f5ca07e92b4900d10c79 +https://conda.anaconda.org/conda-forge/linux-64/libbtf-2.3.2-hf02c80a_7100101.conda#6f4aec52002defbdf3e24eb79e56a209 +https://conda.anaconda.org/conda-forge/linux-64/libcamd-3.3.3-hf02c80a_7100101.conda#1c9d1532caadece8adc2d14c6d4fc726 +https://conda.anaconda.org/conda-forge/linux-64/libccolamd-3.3.4-hf02c80a_7100101.conda#e5107e02dc4c2f9f41eef72d72c23517 +https://conda.anaconda.org/conda-forge/linux-64/libcolamd-3.3.4-hf02c80a_7100101.conda#56d4c5542887e8955f21f8546ad75d9d +https://conda.anaconda.org/conda-forge/linux-64/libcxsparse-4.4.1-hf02c80a_7100101.conda#917931d508582ef891bbac172294d9fb +https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda#f4084e4e6577797150f9b04a4560ceb0 +https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.1-h0d30a3d_2.conda#17d484ab9c8179c6a6e5b7dbb5065afc +https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.11.2-h174a0a3_1.conda#850f48943d6b4589800a303f0de6a816 +https://conda.anaconda.org/conda-forge/linux-64/libldl-3.3.2-hf02c80a_7100101.conda#19b71122fea7f6b1c4815f385b2da419 +https://conda.anaconda.org/conda-forge/linux-64/librbio-4.3.4-hf02c80a_7100101.conda#4b3a3d711d1c1f76f7f440e51458f512 +https://conda.anaconda.org/anaconda/linux-64/libtheora-1.2.0-h32ad74f_1.tar.bz2#e75eb46678cbc5e669556fd9c1f0ff92 +https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.14.6-he237659_3.conda#57e0f440064e9b70007233d53ce4835d +https://conda.anaconda.org/anaconda/linux-64/minizip-4.2.1-h7e49174_0.tar.bz2#3cfa31da6bd36a7c03dede43473570a4 +https://conda.anaconda.org/anaconda/linux-64/mpfr-4.2.1-h5eee18b_0.tar.bz2#209884aebdcc837565aa6e306775d860 +https://conda.anaconda.org/anaconda/linux-64/ninja-1.13.1-h06a4308_0.tar.bz2#3a7f1a36c24aeb916b20bf93c4d30b55 +https://conda.anaconda.org/conda-forge/linux-64/openblas-0.3.33-pthreads_h6ec200e_0.conda#7ed92a9ace1e050a2eca9fe50aa94c81 +https://conda.anaconda.org/anaconda/linux-64/packaging-26.0-py310h06a4308_0.tar.bz2#6c73ca2540d30d745990a391e6ca9390 +https://conda.anaconda.org/anaconda/linux-64/setuptools-82.0.1-py310h06a4308_0.tar.bz2#d1f788eb988f6012f9195e690a08f0f8 +https://conda.anaconda.org/anaconda/linux-64/shaderc-2025.5-hf5cb0ee_1.tar.bz2#1726781c6d4a5447ca3fae1974f97e7a +https://conda.anaconda.org/anaconda/linux-64/wayland-1.24.0-hdac8c69_0.tar.bz2#b8c7d45a7462370f54810cc8de98b82a +https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda#1c74ff8c35dcadf952a16f752ca5aa49 +https://conda.anaconda.org/anaconda/linux-64/xorg-libx11-1.8.12-h9b100fa_1.tar.bz2#f0b98becde0651fe764283bce9423e87 +https://conda.anaconda.org/anaconda/linux-64/aws-c-auth-0.10.1-h47b2149_0.tar.bz2#b70526637997da9355971007ee103ab2 +https://conda.anaconda.org/anaconda/linux-64/dbus-1.16.2-h5bd4931_0.tar.bz2#30954fc32a036cff065831075366c405 +https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.17.1-h27c8c51_0.conda#867127763fbe935bab59815b6e0b7b5c +https://conda.anaconda.org/anaconda/linux-64/freexl-2.0.0-hf309648_0.tar.bz2#66fba742a11cdbb9293ea2ec9064182a +https://conda.anaconda.org/anaconda/linux-64/gcc-14.3.0-h83c8b5c_4.tar.bz2#da262d9d2a3b3d6d3f0cb58ac105710d +https://conda.anaconda.org/anaconda/linux-64/glib-tools-2.86.3-h9fad118_0.tar.bz2#d040332e70ce2ce7500dc5c1367dd8de +https://conda.anaconda.org/anaconda/linux-64/gstreamer-1.28.4-hbd6b80e_0.tar.bz2#377939709343e598e92630b30b4249ad +https://conda.anaconda.org/anaconda/linux-64/gxx_linux-64-14.3.0-hca8765c_14.tar.bz2#fe67aa0b8cb86789910cfd9c0fb1f9b0 +https://conda.anaconda.org/conda-forge/linux-64/libarchive-3.8.8-gpl_hc2c16d8_100.conda#44652e646cb623f486ea72e7e7479222 +https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-8_h0358290_openblas.conda#33a413f1095f8325e5c30fde3b0d2445 +https://conda.anaconda.org/anaconda/linux-64/libflac-1.5.0-hb705b29_1.tar.bz2#e027cd52cd1668959298918f397b6bdf +https://conda.anaconda.org/anaconda/linux-64/libglx-1.7.0-h5eee18b_2.tar.bz2#a332f571d677ae17037f0216eb3f0a6c +https://conda.anaconda.org/anaconda/linux-64/libidn2-2.3.8-hf80d704_0.tar.bz2#8b1b7dd7acbcaf5abd981a74c7271b51 +https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-8_h47877c9_openblas.conda#809be8ba8712c77bc7d44c2d99390dc4 +https://conda.anaconda.org/anaconda/linux-64/libnghttp2-1.69.0-hc59f8b6_0.tar.bz2#0c2ab1332fa6352b279d9dcb790923ad +https://conda.anaconda.org/conda-forge/linux-64/libspex-3.2.3-h9226d62_7100101.conda#63323b258079a75133ccecbb0902614d +https://conda.anaconda.org/conda-forge/linux-64/libxml2-devel-2.14.6-he237659_3.conda#09dca44f7859ebec4ceaca3a63661a4a +https://conda.anaconda.org/anaconda/linux-64/openjpeg-2.5.4-h4e0627c_1.tar.bz2#89763f53802a4e24e3c9c9d23fb7b9d7 +https://conda.anaconda.org/anaconda/linux-64/wayland-protocols-1.47-h51f6af0_0.tar.bz2#f433b31955b1f81373045bb5963082ef +https://conda.anaconda.org/anaconda/linux-64/wheel-0.47.0-py310h06a4308_0.tar.bz2#57f7610bbd953b0d6f15c008f7045251 +https://conda.anaconda.org/anaconda/linux-64/xorg-libxext-1.3.6-h9b100fa_0.tar.bz2#8187c859d4a6d3c4e6c555b1ea5e6c7e +https://conda.anaconda.org/anaconda/linux-64/xorg-libxfixes-6.0.1-h9b100fa_0.tar.bz2#998ddb160cee705515d2ccce022b4557 +https://conda.anaconda.org/anaconda/linux-64/xorg-libxrender-0.9.12-h9b100fa_0.tar.bz2#a948f60eeaa06c1dea46f17d26a3636a +https://conda.anaconda.org/anaconda/linux-64/aws-c-s3-0.12.0-h1b28b03_1.tar.bz2#3a1a6f0d8297f67c07812a7781ea694d +https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda#abd85120de1187b0d1ec305c2173c71b +https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda#bb6c4808bfa69d6f7f6b07e5846ced37 +https://conda.anaconda.org/anaconda/linux-64/glib-2.86.3-h617169b_0.tar.bz2#22144b0f5719c53194811e8802994f6b +https://conda.anaconda.org/anaconda/linux-64/gxx-14.3.0-h36cfa25_4.tar.bz2#013678607ef669179b8c4abe1ebb6548 +https://conda.anaconda.org/conda-forge/linux-64/leptonica-1.87.0-h12c84f9_1.conda#a1500cb93ee16f52d4608265c0309e09 +https://conda.anaconda.org/conda-forge/linux-64/libcholmod-5.3.1-h9cf07ce_7100101.conda#f51e24ce110ae24c92074736a308e47e +https://conda.anaconda.org/anaconda/linux-64/libcurl-8.20.0-hd8fa685_1.tar.bz2#45307f6cc7f6a547b37ab0c4ba72e4c6 +https://conda.anaconda.org/anaconda/linux-64/libgl-1.7.0-h5eee18b_2.tar.bz2#3ada29c9c16f5d453cf1cc64b54b58a5 +https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-8_h6ae95b6_openblas.conda#da17457f689df5c0f246d2f0b3798fd2 +https://conda.anaconda.org/anaconda/linux-64/libsndfile-1.2.2-ha54c36a_1.tar.bz2#91fa54370ce6bf1cb28098760c406e96 +https://conda.anaconda.org/anaconda/noarch/pip-26.1.2-pyhc872135_0.tar.bz2#0a548b658ae0d1daf6edeed3853a1e08 +https://conda.anaconda.org/anaconda/linux-64/xorg-libxi-1.8.2-h9b100fa_0.tar.bz2#bff9d8c7d868a325c20217711b83d4bf +https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.11.0-8_h1ea3ea9_openblas.conda#c36b9a7a135105b504069c8c832c72df +https://conda.anaconda.org/conda-forge/linux-64/cmake-3.31.8-hc85cc9f_0.conda#057e16a12847eea846ecf99710d3591b +https://conda.anaconda.org/conda-forge/linux-64/cxx-compiler-1.11.0-hfcd1e18_0.conda#5da8c935dca9186673987f79cef0b2a5 +https://conda.anaconda.org/anaconda/linux-64/gdk-pixbuf-2.44.6-h268f190_1.tar.bz2#0bd1ba550db8dc63b1d32b420804a32a +https://conda.anaconda.org/anaconda/linux-64/gst-plugins-base-1.28.4-h446d7a6_0.tar.bz2#d5c0725e8a545d6ecc3d9617c8a91d92 +https://conda.anaconda.org/anaconda/linux-64/harfbuzz-12.3.0-h572a7f1_2.tar.bz2#61a48bb38e042137ca24772a441c8919 +https://conda.anaconda.org/anaconda/linux-64/hdf5-2.0.0-h91801a2_3.tar.bz2#1d0a087f5004b2fb82005cedbc7349a2 +https://conda.anaconda.org/conda-forge/linux-64/libklu-2.3.5-h95ff59c_7100101.conda#efaa5e7dc6989363585fbb591480b256 +https://conda.anaconda.org/conda-forge/linux-64/libspqr-4.3.4-h23b7119_7100101.conda#c1ee33a71065c1f0efd9c8174d5f18b0 +https://conda.anaconda.org/conda-forge/linux-64/libumfpack-6.3.5-h873dde6_7100101.conda#9626fc7667bc6c901c7a0a4004938c71 +https://conda.anaconda.org/anaconda/linux-64/libva-2.23.0-h80f46ad_0.tar.bz2#8952aff232c65fc47c9aafd478d01ab4 +https://conda.anaconda.org/conda-forge/linux-64/proj-9.8.1-he0df7b0_0.conda#b23619e5e9009eaa070ead0342034027 +https://conda.anaconda.org/anaconda/linux-64/pulseaudio-client-17.0-hec24eaf_1.tar.bz2#013d9a26c7205d4b2aca8d71175ba205 +https://conda.anaconda.org/anaconda/linux-64/tesseract-5.2.0-hc7272f1_5.tar.bz2#1fbbd31d4aee21b8c78d090ab58c3e39 +https://conda.anaconda.org/anaconda/linux-64/xorg-libxtst-1.2.5-h9b100fa_3.tar.bz2#9c37dfef2a721334db27fafb1f6220a0 +https://conda.anaconda.org/conda-forge/linux-64/blas-2.308-openblas.conda#c92473046ff58a2c2bedfccc9209f7bd +https://conda.anaconda.org/anaconda/linux-64/intel-media-driver-26.1.0-h7354ed3_0.tar.bz2#5ac3b54a435683ff70d76339cf40423b +https://conda.anaconda.org/anaconda/linux-64/libass-0.17.4-h682e061_2.tar.bz2#3a4a6873a0d25579491c30144d848e31 +https://conda.anaconda.org/conda-forge/linux-64/libparu-1.0.0-hc6afc67_7100101.conda#fd1d3e26c1b12c70f7449369ae3d9c1a +https://conda.anaconda.org/conda-forge/linux-64/libspatialite-5.1.0-gpl_hab3fe16_120.conda#5947bdda89ced07ab0d8a5d6503082c3 +https://conda.anaconda.org/anaconda/linux-64/pango-1.56.4-h026457f_0.tar.bz2#729ca9bf6cfd8e95e44cdce74b09ce1e +https://conda.anaconda.org/anaconda/linux-64/pulseaudio-daemon-17.0-h68d7d8d_1.tar.bz2#a1d14bcb124240e4fda66ec7a0cbcc37 +https://conda.anaconda.org/conda-forge/linux-64/libgdal-core-3.13.1-h2c8e1a9_2.conda#c55622ce7c6d453642fd3ad737f42bd7 +https://conda.anaconda.org/anaconda/linux-64/librsvg-2.62.1-h4367520_0.tar.bz2#f1a818d7add4f0821b3236ec7dd49860 +https://conda.anaconda.org/anaconda/linux-64/libvpl-2.15.0-h7354ed3_0.tar.bz2#bff2702b43043ccd37c990cde5bd6223 +https://conda.anaconda.org/anaconda/linux-64/numpy-base-2.2.5-py310h4bc27c9_3.tar.bz2#50114b05111071c36648075d9fa00767 +https://conda.anaconda.org/anaconda/linux-64/pulseaudio-17.0-h7354ed3_1.tar.bz2#a3eb122148669b7ca02b9c92befa56ab +https://conda.anaconda.org/conda-forge/linux-64/suitesparse-7.10.1-h5b2951e_7100101.conda#e927e0f248a498050443dab8bb1203a9 +https://conda.anaconda.org/conda-forge/linux-64/ceres-solver-2.2.0-cpugplh3250042_211.conda#d68f4cea2931842ade9a85ce4803b352 +https://conda.anaconda.org/anaconda/linux-64/ffmpeg-8.1.2-h5758e9d_0.tar.bz2#751ae4157af6751fac35fe57418c9c5f +https://conda.anaconda.org/anaconda/linux-64/numpy-2.2.5-py310h35d6838_3.tar.bz2#aed37dfb71c69b0b052d235124e3517f +https://conda.anaconda.org/conda-forge/linux-64/gdal-3.13.1-np2py310h1cad03b_2.conda#96623720b6a822de56e47a740c520084 +https://conda.anaconda.org/anaconda/linux-64/opencv-4.13.0-headless_py310h68011f4_8.tar.bz2#2b352027378d662ddc7b24d2f24805ca +https://conda.anaconda.org/anaconda/linux-64/libopencv-4.13.0-headless_py310h05a7c19_8.tar.bz2#27d46dce6726f61a8d73e37b73de16de +https://conda.anaconda.org/anaconda/linux-64/py-opencv-4.13.0-headless_py310hc4f99ae_8.tar.bz2#3a79faa2047f7201beddca5fe455f1e6 diff --git a/conda-osx-arm64.lock b/conda-osx-arm64.lock new file mode 100644 index 000000000..b1f667251 --- /dev/null +++ b/conda-osx-arm64.lock @@ -0,0 +1,212 @@ +# Generated by conda-lock. +# platform: osx-arm64 +# input_hash: 4fe6a8bb28e0fca1fb0ecbaf2b08a88ce8d72a71fc2d832180dcce144d810776 +@EXPLICIT +https://conda.anaconda.org/conda-forge/osx-arm64/dav1d-1.2.1-hb547adb_0.conda#5a74cdee497e6b65173e10d94582fae6 +https://conda.anaconda.org/conda-forge/osx-arm64/eigen-5.0.1-h44d0d2d_0.conda#56a644c825e7ea8da0866fcc016190f3 +https://conda.anaconda.org/conda-forge/osx-arm64/eigen-abi-5.0.1.100-h485a483_0.conda#517f7185d37c1fab8c8220c1110d82cb +https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2#0c96522c6bdaed4b1566d11387caaf45 +https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2#34893075a5c9e55cdafac56607368fc6 +https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb +https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda#49023d73832ef61042f6a237cb2687e7 +https://conda.anaconda.org/conda-forge/osx-arm64/giflib-5.2.2-h93a5062_0.conda#95fa1486c77505330c20f7202492b913 +https://conda.anaconda.org/conda-forge/osx-arm64/lame-3.100-h1a8c8d9_1003.tar.bz2#bff0e851d66725f78dc2fd8b032ddb7e +https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda#36d33e440c31857372a72137f78bacf5 +https://conda.anaconda.org/conda-forge/osx-arm64/libxcrypt-4.4.36-h93a5062_1.conda#7d2e687b217d4de0fa7064b4de3e0be8 +https://conda.anaconda.org/anaconda/osx-arm64/metis-5.1.0-hc377ac9_0.tar.bz2#e3d5244974dc067d66fbcdca134cdd84 +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.10-8_cp310.conda#05e00f3b21e88bb3d658ac700b2ce58c +https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-arm64-26.0-ha3f98da_7.conda#5f0ebbfea12d8e5bddff157e271fdb2f +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/osx-arm64/x264-1!164.3095-h57fd34a_2.tar.bz2#b1f6dccde5d3a1f911960b6e567113ff +https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda#620b85a3f45526a8bc4d23fd78fc22f0 +https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda#bcb3cba70cf1eec964a03b4ba7775f01 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda#a9965dd99f683c5f444428f896635716 +https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda#a7970cd949a077b7cb9696379d338681 +https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-hc919400_0.conda#04bdce8d93a4ed181d1d726163c2d447 +https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda#f1182c91c0de31a7abd40cedf6a5ebef +https://conda.anaconda.org/conda-forge/osx-arm64/json-c-0.18-he4178ee_0.conda#94f14ef6157687c30feb44e1abecd577 +https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda#006e7ddd8a110771134fcc4e1e3a6ffa +https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda#89f76a2a21a3ec3ec983b5eb237c4113 +https://conda.anaconda.org/conda-forge/noarch/libcxx-headers-19.1.7-h707e725_2.conda#de91b5ce46dc7968b6e311f9add055a2 +https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda#a6130c709305cd9828b4e1bd9ba0000c +https://conda.anaconda.org/conda-forge/osx-arm64/libdovi-3.3.2-h78f8ca3_4.conda#6ece15d35513fb9543cf45310cda72e1 +https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda#a915151d5d3c5bf039f5ccc8402a436f +https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda#43c04d9cb46ef176bb2a4c77e324d599 +https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda#4d5a7445f0b25b6a3ddbb56e790f5251 +https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.4.1-h84a0fba_0.conda#b8a7544c83a67258b0e8592ec6a5d322 +https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda#b1fd823b5ae54fbec272cea0811bd8a9 +https://conda.anaconda.org/conda-forge/osx-arm64/libogg-1.3.5-h48c0fde_1.conda#29b8b11f6d7e6bd0e76c029dcf9dd024 +https://conda.anaconda.org/conda-forge/osx-arm64/libopus-1.6.1-h1a92334_0.conda#7f414dd3fd1cb7a76e51fec074a9c49e +https://conda.anaconda.org/conda-forge/osx-arm64/libusb-1.0.29-hbc156a2_0.conda#f6654e9e96e9d973981b3b2f898a5bfa +https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda#fa9fef7d9f33724b7c3899c883c25a3e +https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda#e5e7d467f80da752be17796b87fe6385 +https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda#bc5a5721b6439f2f62a84f2548136082 +https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.8-hc7d1edf_0.conda#a9c118f6343fb6301b6f3b4e94c4c562 +https://conda.anaconda.org/conda-forge/osx-arm64/lzo-2.10-h925e9cb_1002.conda#e56eaa1beab0e7fed559ae9c0264dd88 +https://conda.anaconda.org/conda-forge/osx-arm64/make-4.4.1-hc9fafa5_2.conda#9f44ef1fea0a25d6a3491c58f3af8460 +https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda#343d10ed5b44030a2f67193905aea159 +https://conda.anaconda.org/conda-forge/osx-arm64/rav1e-0.8.1-h8246384_0.conda#4706a8a71474c692482c3f86c2175454 +https://conda.anaconda.org/conda-forge/osx-arm64/rhash-1.4.6-h5505292_1.conda#029e812c8ae4e0d4cf6ff4f7d8dc9366 +https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda#a44032f282e7d2acdeb1c240308052dd +https://conda.anaconda.org/conda-forge/osx-arm64/aom-3.14.1-pl5321h513545f_1.conda#506b0327a51de9871ce2725022c0c955 +https://conda.anaconda.org/conda-forge/osx-arm64/fast_float-8.1.0-ha7d2532_0.conda#743b68df8d55a47ab5f910b8241dfc32 +https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab +https://conda.anaconda.org/conda-forge/osx-arm64/geos-3.14.1-h5afe852_0.conda#4238412c29eff0bb2bb5c60a720c035a +https://conda.anaconda.org/conda-forge/osx-arm64/gflags-2.2.2-hf9b8971_1005.conda#57a511a5905caa37540eb914dfcbf1fb +https://conda.anaconda.org/conda-forge/osx-arm64/gmp-6.3.0-h7bae524_2.conda#eed7278dfbab727b56f2c0b64330814b +https://conda.anaconda.org/conda-forge/osx-arm64/graphite2-1.3.15-hf6b4638_0.conda#0d576cff278a2e60456d5b2c0a1ffda3 +https://conda.anaconda.org/conda-forge/osx-arm64/imath-3.2.2-h3470cca_0.conda#c1d09757f796cafb99077b8935c6239f +https://conda.anaconda.org/conda-forge/osx-arm64/laszip-3.4.3-hbdafb3b_2.tar.bz2#66960d0b562f9380f5bf5db93a1af633 +https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.1.0-h1eee2c3_0.conda#095e5749868adab9cae42d4b460e5443 +https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260107.1-cxx17_h2062a1b_0.conda#bb65152e0d7c7178c0f1ee25692c9fd1 +https://conda.anaconda.org/conda-forge/osx-arm64/libaec-1.1.5-h8664d51_0.conda#13e6d9ae0efbc9d2e9a01a91f4372b41 +https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda#079e88933963f3f149054eec2c487bc2 +https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda#b2b7c8288ca1a2d71ff97a8e6a1e8883 +https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-devel-19.1.7-h6dc3340_2.conda#9f7810b7c0a731dbc84d46d6005890ef +https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda#44083d2d2c2025afca315c7a172eab2b +https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-ha332bbd_0.conda#7394850583ca88325244b68b532c7a39 +https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda#5103f6a6b210a3912faf8d7db516918c +https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.58-h132b30e_0.conda#2259ae0949dbe20c0665850365109b27 +https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.2-h1ae2325_1.conda#2749be4dc52f3a6699e9dea29b78410a +https://conda.anaconda.org/conda-forge/osx-arm64/libvorbis-1.3.7-h81086ad_2.conda#719e7653178a09f5ca0aa05f349b41f7 +https://conda.anaconda.org/conda-forge/osx-arm64/libvpx-1.15.2-ha759d40_0.conda#0d2febd301e25a48e00447b300d68f9c +https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.341.0-h3feff0a_0.conda#6b4c9a5b130759136a0dde0c373cb0ea +https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h5ef1a60_0.conda#19edaa53885fc8205614b03da2482282 +https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-h286801f_1.conda#01511afc6cc1909c5303cf31be17b44f +https://conda.anaconda.org/conda-forge/osx-arm64/muparser-2.3.5-h11e0b38_0.conda#1cdbe54881794ee356d3cba7e3ed6668 +https://conda.anaconda.org/conda-forge/osx-arm64/ninja-1.13.2-h49c215f_0.conda#175809cc57b2c67f27a0f238bd7f069d +https://conda.anaconda.org/conda-forge/osx-arm64/opencl-headers-2025.06.13-hf6b4638_0.conda#0a7a5d256fa6d858d5b496843a161e66 +https://conda.anaconda.org/conda-forge/osx-arm64/openh264-2.6.0-hb5b2745_0.conda#6ff0890a94972aca7cc7f8f8ef1ff142 +https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda#8187a86242741725bfa74785fe812979 +https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda#9b4190c4055435ca3502070186eba53a +https://conda.anaconda.org/conda-forge/osx-arm64/pixman-0.46.4-h81086ad_1.conda#17c3d745db6ea72ae2fce17e7338547f +https://conda.anaconda.org/conda-forge/osx-arm64/pugixml-1.15-hd3d436d_0.conda#b9a4004e46de7aeb005304a13b35cb94 +https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda#f8381319127120ce51e081dce4865cf4 +https://conda.anaconda.org/conda-forge/osx-arm64/snappy-1.2.2-hada39a4_1.conda#fca4a2222994acd7f691e57f94b750c5 +https://conda.anaconda.org/conda-forge/osx-arm64/spirv-tools-2026.2-h4ddebb9_0.conda#b08f1917b02b1877a13119de24ead63f +https://conda.anaconda.org/conda-forge/osx-arm64/svt-av1-4.0.1-h0cb729a_0.conda#8badc3bf16b62272aa2458f138223821 +https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-h997e182_2.conda#555070ad1e18b72de36e9ee7ed3236b3 +https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda#a9d86bc62f39b94c4661716624eb21b0 +https://conda.anaconda.org/conda-forge/osx-arm64/uriparser-0.9.8-h00cdb27_0.conda#e8ff9e11babbc8cd77af5a4258dc2802 +https://conda.anaconda.org/conda-forge/osx-arm64/x265-3.5-hbc6ce65_3.tar.bz2#b1f7f2780feffe310b068c021e8ff9b2 +https://conda.anaconda.org/conda-forge/osx-arm64/xerces-c-3.3.0-h25f632f_1.conda#0b886d06130b774f086d3b2ce0b7277a +https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_2.conda#f1c0bce276210bed45a04949cfe8dc20 +https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda#ab136e4c34e97f34fb621d2592a393d8 +https://conda.anaconda.org/conda-forge/osx-arm64/blosc-1.21.6-h7dd00d9_1.conda#925acfb50a750aa178f7a0aced77f351 +https://conda.anaconda.org/conda-forge/osx-arm64/glog-0.7.1-heb240a5_0.conda#fef68d0a95aa5b84b5c1a4f6f3bf40e1 +https://conda.anaconda.org/conda-forge/osx-arm64/glslang-16.3.0-h7cb4797_0.conda#85d9c709161737695252660b174b36f2 +https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-hfd3d5f3_1.conda#8780f41b013d19219faef9c82260744b +https://conda.anaconda.org/conda-forge/osx-arm64/libavif16-1.4.2-h1c659b6_1.conda#91706a918713506ef5a43ecc8e0a7e6b +https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.3-hdfa99f5_1.conda#a0bb0678f67c464938d3693fa96f6884 +https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-15.2.0-hcbb3090_19.conda#644058123986582db33aebd4ae2ca184 +https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.1-ha08bb59_2.conda#62d6f3b832d7d79ae0c0aa1bb3c325fa +https://conda.anaconda.org/conda-forge/osx-arm64/libjxl-0.11.2-h934fa54_1.conda#05bead8980f5ae6a070117dacec38b5b +https://conda.anaconda.org/conda-forge/osx-arm64/libkml-1.3.0-hb833057_1023.conda#3d8eeedb8e541d1cb593e8204fcc397d +https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda#6ea18834adbc3b33df9bd9fb45eaf95b +https://conda.anaconda.org/conda-forge/osx-arm64/libprotobuf-6.33.5-h2d4b707_1.conda#300fdae9d7ad150a90755f55b0a8a7a8 +https://conda.anaconda.org/conda-forge/osx-arm64/librttopo-1.1.0-ha909e78_20.conda#d07359797436cfc891b38e203cf0caac +https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_0.conda#c08557d00807785decafb932b5be7ef5 +https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-h1590b86_0.conda#b68e8f66b94b44aaa8de4583d3d4cc40 +https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h4030677_1.conda#e2a72ab2fa54ecb6abab2b26cde93500 +https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.conda#8e037d73747d6fe34e12d7bcac10cf21 +https://conda.anaconda.org/conda-forge/osx-arm64/minizip-4.2.1-hdb7fadc_0.conda#fcd860469a8fa003e25d472b40b0ff81 +https://conda.anaconda.org/conda-forge/osx-arm64/mpfr-4.2.2-h6bc93b0_0.conda#a47a14da2103c9c7a390f7c8bc8d7f9b +https://conda.anaconda.org/conda-forge/osx-arm64/python-3.10.20-hac0b6dc_1_cpython.conda#7f498ade7b9aa9e327ad23931e6c6d4a +https://conda.anaconda.org/conda-forge/osx-arm64/sqlite-3.53.2-h77b7338_1.conda#725a35de8e373be31d2e1b5c7ef70a0a +https://conda.anaconda.org/conda-forge/noarch/cpython-3.10.20-py310hd8ed1ab_1.conda#bfe34936b4fa9b1fbe25e68a7abbd8eb +https://conda.anaconda.org/conda-forge/osx-arm64/dbus-1.16.2-h3ff7a7c_1.conda#5a3506971d2d53023c1c4450e908a8da +https://conda.anaconda.org/conda-forge/osx-arm64/freexl-2.0.0-h3ab3353_2.conda#dd655a29b40fe0d1bf95c64cf3cb348d +https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.6-h4e57454_0.conda#e67ebd2f639f46e52af8531622fa6051 +https://conda.anaconda.org/conda-forge/osx-arm64/lcms2-2.19.1-hdfa7624_1.conda#d2f2c7c10e2957647d45589b7701a453 +https://conda.anaconda.org/conda-forge/osx-arm64/libarchive-3.8.8-gpl_h6fbacd7_100.conda#cfa10f3c4b14c13f676dc08e2ea29023 +https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.21.0-hd5a2499_0.conda#862eb5c81e1295f492fa261a68a4233f +https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.3-hce30654_1.conda#0582e67cd14cfed773be2f3b1aba08e0 +https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_19.conda#ba36d8c606a6a53fe0b8c12d47267b3d +https://conda.anaconda.org/conda-forge/osx-arm64/libhwloc-2.13.0-default_ha97f43a_1000.conda#fed55ddd65a830cb62e78f07cfffcd41 +https://conda.anaconda.org/conda-forge/osx-arm64/libllvm19-19.1.7-h8e0c9ce_2.conda#d1d9b233830f6631800acc1e081a9444 +https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-devel-2.15.3-h5654f7c_0.conda#469f4af737803bf7deda25d41c557593 +https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hd9e9057_0.conda#4b5d3a91320976eec71678fad1e3569b +https://conda.anaconda.org/conda-forge/osx-arm64/openjph-0.30.1-h2a4d681_0.conda#1b1d7b258c71a33d01458ad1c8e04d44 +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd +https://conda.anaconda.org/conda-forge/osx-arm64/shaderc-2026.2-hf31e910_0.conda#6e50dd641e624d5921f25a82aea39ae9 +https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_0.conda#ade77ad7513177297b1d75e351e136ce +https://conda.anaconda.org/conda-forge/osx-arm64/cmake-3.31.8-h54ad630_0.conda#7a61882f98f88aee01bae8a76d5990ba +https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.18.1-h2b252f5_0.conda#9d928e6a62192141fb6540a3125b1345 +https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm19_1_ha2625f7_4.conda#eaf3d06e3a8a10dee7565e8d76ae618d +https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp19.1-19.1.7-default_hf3020a7_9.conda#ddb70ebdcbf3a44bddc2657a51faf490 +https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_19.conda#1ea03f87cdb1078fbc0e2b2deb63752c +https://conda.anaconda.org/conda-forge/osx-arm64/libplacebo-7.360.1-h176d363_0.conda#7402fdef0c155dcd18c0ff4c5853c4b2 +https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19-19.1.7-h91fd4e7_2.conda#8237b150fcd7baf65258eef9a0fc76ef +https://conda.anaconda.org/conda-forge/osx-arm64/openexr-3.4.13-hafff71b_1.conda#f062abba9ac3103008873dee4f9eb2d7 +https://conda.anaconda.org/conda-forge/osx-arm64/proj-9.8.1-ha88f16d_0.conda#1d135fa1ea825987507d1e14e4187b1e +https://conda.anaconda.org/conda-forge/noarch/python-gil-3.10.20-hd8ed1ab_1.conda#33296afcbf60357874f99aec38ead969 +https://conda.anaconda.org/conda-forge/osx-arm64/sdl3-3.4.10-h6fa9c73_0.conda#3301738d307bba9ec51f9ce9cf23b842 +https://conda.anaconda.org/conda-forge/osx-arm64/tbb-2023.0.0-he0260a5_2.conda#440c0a36cc20db1f28877a69afbb5e88 +https://conda.anaconda.org/conda-forge/noarch/wheel-0.47.0-pyhd8ed1ab_0.conda#d0e3b2f0030cf4fca58bde71d246e94c +https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda#aaa2a381ccc56eac91d63b6c1240312f +https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-he0f2337_1.conda#36200ecfbbfbcb82063c87725434161f +https://conda.anaconda.org/conda-forge/osx-arm64/clang-19-19.1.7-default_hf3020a7_9.conda#5a77d772c22448f6ab340fbfff55db48 +https://conda.anaconda.org/conda-forge/osx-arm64/hdf5-1.14.6-nompi_had3affe_110.conda#65797138355b90a8e219afcf7e77b273 +https://conda.anaconda.org/conda-forge/osx-arm64/ld64-956.6-llvm19_1_he86490a_4.conda#22eb76f8d98f4d3b8319d40bda9174de +https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.33-openmp_he657e61_0.conda#909e41855c29f0d52ae630198cd57135 +https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-2026.2.1-hd05642e_0.conda#c97c467b953b3723a645ae98872d321a +https://conda.anaconda.org/conda-forge/osx-arm64/libspatialite-5.1.0-gpl_hc59e0ec_120.conda#8909f6e5df2f99065ef12a6e6c2db274 +https://conda.anaconda.org/conda-forge/osx-arm64/libsuitesparseconfig-7.10.1-h4a8fc20_7100102.conda#7ffecea6d807f0bd69a3e136a409ced3 +https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19.1.7-h855ad52_2.conda#3e3ac06efc5fdc1aa675ca30bf7d53df +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda#511fbc2c63d2c73650ad1755e4d357ba +https://conda.anaconda.org/conda-forge/osx-arm64/sdl2-2.32.56-h248ca61_0.conda#092c5b693dc6adf5f409d12f33295a2a +https://conda.anaconda.org/conda-forge/osx-arm64/cctools_impl_osx-arm64-1030.6.3-llvm19_1_he8a363d_4.conda#76c651b923e048f3f3e0ecb22c966f70 +https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.2.1-h3103d1b_0.conda#389b1c7cb4738fa74f8a142336807a13 +https://conda.anaconda.org/conda-forge/osx-arm64/libamd-3.3.3-h5087772_7100102.conda#0c30185fa04e8b5c78f1f70e6e501bec +https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-8_h51639a9_openblas.conda#dbfe729181a32741ae63ecb41eefbac6 +https://conda.anaconda.org/conda-forge/osx-arm64/libbtf-2.3.2-h99b4a89_7100102.conda#bb83a609dcf66d5ac2fd666888788c16 +https://conda.anaconda.org/conda-forge/osx-arm64/libcamd-3.3.3-h99b4a89_7100102.conda#9c61b6733f2167a84d08d97a9f2d6f88 +https://conda.anaconda.org/conda-forge/osx-arm64/libccolamd-3.3.4-h99b4a89_7100102.conda#14092975663a3b6139a8891b8f56151b +https://conda.anaconda.org/conda-forge/osx-arm64/libcolamd-3.3.4-h99b4a89_7100102.conda#89673c8b6f5efcce6e92f5269996cc40 +https://conda.anaconda.org/conda-forge/osx-arm64/libcxsparse-4.4.1-h9e79f82_7100102.conda#d3b711fc46f75a04e71738d3e2c4fdc9 +https://conda.anaconda.org/conda-forge/osx-arm64/libgdal-core-3.13.1-hcee3826_2.conda#6697ff3aaeffab689f7344c8adffa9eb +https://conda.anaconda.org/conda-forge/osx-arm64/libldl-3.3.2-h99b4a89_7100102.conda#fe46ab585d717eeaf157c5111e076d0a +https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-arm-cpu-plugin-2026.2.1-hd05642e_0.conda#616c52a19cc52ba63663d3c6893f569c +https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-auto-batch-plugin-2026.2.1-h4f620ee_0.conda#63444448cc5815092c549d69de1d0d2f +https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-auto-plugin-2026.2.1-h4f620ee_0.conda#0a74d4168cadc60ecfcd264849c68273 +https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-hetero-plugin-2026.2.1-h85cbfa6_0.conda#b81669add82aeb3cd91cece122fc8cec +https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-ir-frontend-2026.2.1-h85cbfa6_0.conda#28abf17a1e589c38e548e3d0ca82b080 +https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-onnx-frontend-2026.2.1-h41365f2_0.conda#0666441b9449e2881a9b8779bcadd0d6 +https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-paddle-frontend-2026.2.1-h41365f2_0.conda#bd7d05e44936cb51bc913ed8c8531d40 +https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-pytorch-frontend-2026.2.1-hf6b4638_0.conda#233ec3d2f810275a790334ba594d7ee7 +https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-tensorflow-frontend-2026.2.1-hc295da0_0.conda#dffdf1bd0585e7c569ee5d55ec8ce520 +https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-tensorflow-lite-frontend-2026.2.1-hf6b4638_0.conda#fb12b5fa9aef86a20fb9b0d93eb5d948 +https://conda.anaconda.org/conda-forge/osx-arm64/librbio-4.3.4-h99b4a89_7100102.conda#fa40dbe91ad646bf5abed56855a8b631 +https://conda.anaconda.org/conda-forge/osx-arm64/openblas-0.3.33-openmp_hea878ba_0.conda#d1b81ee41ccdc2518ca268330cd091af +https://conda.anaconda.org/conda-forge/osx-arm64/cctools-1030.6.3-llvm19_1_hd01ab73_4.conda#caf7c8e48827c2ad0c402716159fe0a2 +https://conda.anaconda.org/conda-forge/osx-arm64/cctools_osx-arm64-1030.6.3-llvm19_1_hd01ab73_4.conda#0d059c5db9d880ff37b2da53bf06509e +https://conda.anaconda.org/conda-forge/osx-arm64/libass-0.17.4-hcbd7ca7_0.conda#0977f4a79496437ff3a2c97d13c4c223 +https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-8_hb0561ab_openblas.conda#03a2ef3491da9e5b4d18c03e9f4b3109 +https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-8_hd9741b5_openblas.conda#85adeb3d469d082dbd9c8c39e36dec57 +https://conda.anaconda.org/conda-forge/osx-arm64/libspex-3.2.3-h15d103f_7100102.conda#36b461a2ccf825c682fe8918b82c2f7a +https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.56.4-hf80efc4_1.conda#4b433508ebb295c05dd3d03daf27f7bb +https://conda.anaconda.org/conda-forge/osx-arm64/libcholmod-5.3.1-hbba04d7_7100102.conda#a780c27386527ac7fe7526415a3b9b23 +https://conda.anaconda.org/conda-forge/osx-arm64/liblapacke-3.11.0-8_h1b118fd_openblas.conda#bd1b597f41e950e2058978497bf35c2e +https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.62.3-he8aa2a2_0.conda#6973724fadafe66ac6e4f1c55c191407 +https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.2.6-py310h4d83441_0.conda#f4bd8ac423d04b3c444b96f2463d3519 +https://conda.anaconda.org/conda-forge/osx-arm64/ffmpeg-8.1.2-gpl_haa6735b_101.conda#294718155b73c528320bb2ee565c9e81 +https://conda.anaconda.org/conda-forge/osx-arm64/gdal-3.13.1-np2py310hf77389c_2.conda#efe5f569fcd156eeff8c199c13238b41 +https://conda.anaconda.org/conda-forge/osx-arm64/libklu-2.3.5-h4370aa4_7100102.conda#37896b0b2e01cbe2de5f25f645bc881e +https://conda.anaconda.org/conda-forge/osx-arm64/libspqr-4.3.4-h775d698_7100102.conda#cbac21c5e5ffcd4bcee5dba052535565 +https://conda.anaconda.org/conda-forge/osx-arm64/libumfpack-6.3.5-h7c2c975_7100102.conda#ca1a54d25f34317fecb0a134e94d3cab +https://conda.anaconda.org/conda-forge/osx-arm64/libopencv-4.13.0-headless_h679ab3a_12.conda#d4bc47576bd924fec8e8ca78bf4a484f +https://conda.anaconda.org/conda-forge/osx-arm64/libparu-1.0.0-h317a14d_7100102.conda#a4a9867ec265528a89b4759951957504 +https://conda.anaconda.org/conda-forge/osx-arm64/py-opencv-4.13.0-headless_hc6da5dd_12.conda#0fb4e2dea11663ff44f4e93ce0f96e45 +https://conda.anaconda.org/conda-forge/osx-arm64/suitesparse-7.10.1-h3071b36_7100102.conda#6c58a1e4f8a473cac74f660bc996bafa +https://conda.anaconda.org/conda-forge/osx-arm64/ceres-solver-2.2.0-cpugplh6fe7525_211.conda#f145c3eaf495b14f4a8ee8f04c0bc0e7 +https://conda.anaconda.org/conda-forge/osx-arm64/c-compiler-1.11.0-h61f9b84_0.conda#148516e0c9edf4e9331a4d53ae806a9b +https://conda.anaconda.org/conda-forge/osx-arm64/clang-19.1.7-default_hf9bcbb7_9.conda#20056c993a8c9df01e04a0e165579ec1 +https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-19.1.7-he32a8d3_1.conda#8d99c82e0f5fed6cc36fcf66a11e03f0 +https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-19.1.7-h855ad52_1.conda#39451684370ae65667fa5c11222e43f7 +https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-19.1.7-default_hc11f16d_9.conda#2aec2e39be3b4999bda2a3e5bd4cd2e6 +https://conda.anaconda.org/conda-forge/osx-arm64/clang_osx-arm64-19.1.7-h75f8d18_32.conda#6335db5834eb69811c46f2c9fa2537e2 +https://conda.anaconda.org/conda-forge/osx-arm64/clangxx_impl_osx-arm64-19.1.7-default_hc11f16d_9.conda#8b7425e84f940861653c919142435bde +https://conda.anaconda.org/conda-forge/osx-arm64/clangxx-19.1.7-default_hc995acf_9.conda#9a1ac8e5124fcc201adb20a103d51cc6 +https://conda.anaconda.org/conda-forge/osx-arm64/clangxx_osx-arm64-19.1.7-h75f8d18_32.conda#5b988168322ccd3a656ddc00b6b30da1 +https://conda.anaconda.org/conda-forge/osx-arm64/cxx-compiler-1.11.0-h88570a1_0.conda#043afed05ca5a0f2c18252ae4378bdee diff --git a/conda-win-64.lock b/conda-win-64.lock new file mode 100644 index 000000000..0bb0c1157 --- /dev/null +++ b/conda-win-64.lock @@ -0,0 +1,176 @@ +# Generated by conda-lock. +# platform: win-64 +# input_hash: e2217dc0cd432704529898062fbb7dd005480eae4316b59d7a13ea7d46c4efbf +@EXPLICIT +https://conda.anaconda.org/anaconda/win-64/_openmp_mutex-4.5-52_llvm.tar.bz2#d5201cbf9b8d5d0c9f2740cf38671e0b +https://conda.anaconda.org/anaconda/win-64/ca-certificates-2026.5.14-haa95532_0.tar.bz2#6fcde8fa53214ed2b2a4e9101ec6d8de +https://conda.anaconda.org/conda-forge/win-64/eigen-abi-5.0.1.100-hc11354d_0.conda#53919396018421266fa1a78b537394cc +https://conda.anaconda.org/anaconda/noarch/font-ttf-dejavu-sans-mono-2.37-hd3eb1b0_0.tar.bz2#801b4743a301a4e11cbd9609d418e101 +https://conda.anaconda.org/anaconda/noarch/font-ttf-inconsolata-2.001-hcb22688_0.tar.bz2#d4a96c0ce14ac06cbd80976fa8439fb7 +https://conda.anaconda.org/anaconda/noarch/font-ttf-source-code-pro-2.030-hd3eb1b0_0.tar.bz2#947240b599a47f2633706c6bdc591a17 +https://conda.anaconda.org/anaconda/noarch/font-ttf-ubuntu-0.83-h8b1ccd4_0.tar.bz2#0b017158a7cd7c1683e00b4ae688dce4 +https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_908.conda#9c9303e08b50e09f5c23e1dac99d0936 +https://conda.anaconda.org/anaconda/win-64/python_abi-3.10-3_cp310.tar.bz2#dd185883263018563eca7243a40a8f24 +https://conda.anaconda.org/anaconda/noarch/tzdata-2026b-he532380_0.tar.bz2#148ba61be8a94bb3db370256ffd35ecb +https://conda.anaconda.org/anaconda/win-64/ucrt-10.0.22621.0-haa95532_0.tar.bz2#b89556d8841d1ca1d37e1d4b660d58ba +https://conda.anaconda.org/anaconda/win-64/vswhere-3.1.7-haa95532_0.tar.bz2#8cfc026db2b4a2fe96dc2c4e99ecfad8 +https://conda.anaconda.org/anaconda/noarch/fonts-anaconda-1-h8fa9717_0.tar.bz2#7a445a2d9fbe2a88de8f09e9527f4ce1 +https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda#8a86073cf3b343b87d03f41790d8b4e5 +https://conda.anaconda.org/anaconda/win-64/vc14_runtime-14.44.35208-h4927774_12.tar.bz2#66673d441676ef2acfe4ba1c7d01bace +https://conda.anaconda.org/anaconda/win-64/vs2022_win-64-19.44.35207-hc920ad0_12.tar.bz2#19f8efeaa74baeb67ed452c41921c878 +https://conda.anaconda.org/conda-forge/win-64/cxx-compiler-1.11.0-h1c1089f_0.conda#4d94d3c01add44dc9d24359edf447507 +https://conda.anaconda.org/anaconda/noarch/fonts-conda-ecosystem-1-hd3eb1b0_0.tar.bz2#81135874e3942ccc48b79c12ec094849 +https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h8ee18e1_19.conda#cc5d690fc1c629038f13c68e88e65f44 +https://conda.anaconda.org/anaconda/win-64/ninja-base-1.13.1-h512dff2_0.tar.bz2#ab50e6b799547de5f92c3d64a8383e2b +https://conda.anaconda.org/anaconda/win-64/vc-14.3-h2df5915_12.tar.bz2#e6e1b03a130364f0104c157cbbe65cb5 +https://conda.anaconda.org/anaconda/win-64/vs2015_runtime-14.44.35208-ha6b5a95_12.tar.bz2#acfb21286fd2d516ec5e3df6d32c168f +https://conda.anaconda.org/anaconda/win-64/aom-3.13.2-h8ecca34_0.tar.bz2#934e9c3a38fbde5984b23f711d78b071 +https://conda.anaconda.org/anaconda/win-64/aws-c-common-0.12.6-h02ab6af_0.tar.bz2#4a0260e2b4ae3a3f96043db2e6587cb2 +https://conda.anaconda.org/anaconda/win-64/bzip2-1.0.8-h2bbff1b_6.tar.bz2#11e0af09a31e2d5df51c65a342032b85 +https://conda.anaconda.org/anaconda/win-64/dav1d-1.5.3-h6ac4d88_0.tar.bz2#7e9cfc06222996a0fc3a1e830ba22b1b +https://conda.anaconda.org/anaconda/win-64/eigen-5.0.1-h3ba6905_0.tar.bz2#7a949bb0b627ec4710a0ad2589ac4588 +https://conda.anaconda.org/conda-forge/win-64/fast_float-8.1.0-h49e36cd_0.conda#0f46f9dd7525d8429d81845e3f3e7878 +https://conda.anaconda.org/anaconda/win-64/fribidi-1.0.16-h5da207d_1.tar.bz2#ab9d1be852b9771ccf8ce5d22e428408 +https://conda.anaconda.org/conda-forge/win-64/geos-3.14.1-hdade9fe_0.conda#8c75d7e401a4d799ce8d4bb922320967 +https://conda.anaconda.org/anaconda/win-64/gflags-2.3.0-h8074b40_0.tar.bz2#d843c69d753dc943f4e3bcb49024c51c +https://conda.anaconda.org/anaconda/win-64/gmp-6.3.0-h537511b_0.tar.bz2#8295ec2743bd3566f3d6a014e467803a +https://conda.anaconda.org/anaconda/win-64/graphite2-1.3.15-h781baf4_0.tar.bz2#46495104b171736d7f61216fa2928e47 +https://conda.anaconda.org/anaconda/win-64/gstreamer-orc-0.4.42-h614cf89_0.tar.bz2#ac4578242a09f4786080108c8e1f6583 +https://conda.anaconda.org/anaconda/win-64/gtest-1.17.0-h01d6923_0.tar.bz2#345886bf9727f09d7157fdcc1abab5fd +https://conda.anaconda.org/anaconda/win-64/icu-78.3-h99bb849_0.tar.bz2#1972b7d9fe7ac91d9dc313a3d42e0c07 +https://conda.anaconda.org/anaconda/win-64/lame-3.100-hbe34c35_1.tar.bz2#517b3a3c80b8534195506027c5eee6b3 +https://conda.anaconda.org/conda-forge/win-64/laszip-3.4.3-h6538335_1.tar.bz2#249492b81d0355d30304678db532055f +https://conda.anaconda.org/anaconda/win-64/lerc-4.1.0-hd7fb8db_2.tar.bz2#53d2a1103dd9a9a00a8b5e132883a068 +https://conda.anaconda.org/anaconda/win-64/libabseil-20260107.0-cxx17_hc587421_0.tar.bz2#7b9a234e3f903aa08a02a1d483e09946 +https://conda.anaconda.org/anaconda/win-64/libbrotlicommon-1.2.0-h907acca_0.tar.bz2#56e196ce8bea259a3be91d8940f8dcfe +https://conda.anaconda.org/conda-forge/win-64/libdeflate-1.25-h51727cc_0.conda#e77030e67343e28b084fabd7db0ce43e +https://conda.anaconda.org/anaconda/win-64/libexpat-2.8.1-hd7fb8db_1.tar.bz2#9b094777be3ca325eda782b413f7d6c3 +https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda#720b39f5ec0610457b725eb3f396219a +https://conda.anaconda.org/conda-forge/win-64/libhwy-1.4.0-h172a326_0.conda#aeca1cb6665f19e560c1fbd20b5bcf34 +https://conda.anaconda.org/anaconda/win-64/libiconv-1.18-hc89ec93_0.tar.bz2#650e09b86b73fd29172d65fb8b3282df +https://conda.anaconda.org/conda-forge/win-64/libjpeg-turbo-3.1.4.1-hfd05255_0.conda#25a127bad5470852b30b239f030ec95b +https://conda.anaconda.org/anaconda/win-64/libkrb5-1.22.2-h3d06f0e_0.tar.bz2#40cf6f6bf060cc7c1e7a3731b0583b4d +https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda#8f83619ab1588b98dd99c90b0bfc5c6d +https://conda.anaconda.org/anaconda/win-64/libogg-1.3.5-h2bbff1b_1.tar.bz2#c779e8e6abd2612c0f4b2923d7b9c705 +https://conda.anaconda.org/conda-forge/win-64/libopenblas-0.3.33-pthreads_h877e47f_0.conda#51d4c0bc3695f63375d095004dc2597c +https://conda.anaconda.org/anaconda/win-64/libopenjpeg-2.5.4-h02ab6af_1.tar.bz2#bdae6b6bd1f52508c43e7fe5d1e19c93 +https://conda.anaconda.org/anaconda/win-64/libopus-1.6.1-h27dd113_0.tar.bz2#cf2916f3453f895f84f5dc91775df04a +https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.2-hf5d6505_1.conda#b510cd61047daf53a68be8daeb56b426 +https://conda.anaconda.org/conda-forge/win-64/libsuitesparseconfig-7.10.1-h0795de7_7100102.conda#bd5a0476ad625b75629f2b1b136edf19 +https://conda.anaconda.org/anaconda/win-64/libuv-1.52.0-hd0d7782_0.tar.bz2#d5f1861642d21cfe983a3146b8dfc406 +https://conda.anaconda.org/anaconda/win-64/libwebp-base-1.6.0-hbf3958f_0.tar.bz2#eeb1c3e25645f6b4a178a8b98d9e7ef3 +https://conda.anaconda.org/anaconda/win-64/libzlib-1.3.2-h1c6eee0_0.tar.bz2#4fe4b12c26405791358156e04eba058f +https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.8-h4fa8253_0.conda#de3551bf6508d45ca46b714639e52823 +https://conda.anaconda.org/conda-forge/win-64/lz4-c-1.10.0-h2466b09_1.conda#0b69331897a92fac3d8923549d48d092 +https://conda.anaconda.org/anaconda/win-64/lzo-2.10-he774522_2.tar.bz2#3105d19d3b204073d28d2622a63f1de5 +https://conda.anaconda.org/conda-forge/win-64/make-4.4.1-h0e40799_2.conda#77ff648ad9fec660f261aa8ab0949f62 +https://conda.anaconda.org/anaconda/win-64/metis-5.1.0-h6538335_4.tar.bz2#abe820ad9863411a8e5ae386bcae0bc7 +https://conda.anaconda.org/conda-forge/win-64/muparser-2.3.5-he0c23c2_0.conda#013aabb169d59009bdf7d70319360e9b +https://conda.anaconda.org/conda-forge/win-64/opencl-headers-2025.06.13-h826ebb9_0.conda#77af5a2fabd1c8c3eb9e0a7b3d5afc22 +https://conda.anaconda.org/anaconda/win-64/openh264-2.6.0-hfbc89d2_1.tar.bz2#3b68b239b0f6ce4b43193b7bca3dea27 +https://conda.anaconda.org/anaconda/win-64/openssl-3.5.7-hbb43b14_0.tar.bz2#d5aeaa257caf52ce25b9ab58f0dae7dc +https://conda.anaconda.org/anaconda/win-64/pixman-0.46.4-h4043f72_0.tar.bz2#a90b06706795b88b913313bec06734ea +https://conda.anaconda.org/anaconda/win-64/snappy-1.2.2-hab6b7b3_1.tar.bz2#ba9632d870849506421dc45297feecd3 +https://conda.anaconda.org/anaconda/win-64/spirv-tools-2026.1-h169ad4e_0.tar.bz2#5532808c871b03c89603e67f719a146b +https://conda.anaconda.org/anaconda/win-64/sqlite-3.53.2-hee5a0db_0.tar.bz2#1e2e09649f38da20402f99858edf5654 +https://conda.anaconda.org/anaconda/win-64/svt-av1-3.1.2-hd7fb8db_0.tar.bz2#e100969ffcb99a9061bcce3302c24ec7 +https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda#0481bfd9814bf525bd4b3ee4b51494c4 +https://conda.anaconda.org/anaconda/win-64/uriparser-0.9.8-h1a0bd13_0.tar.bz2#347f285824e8b169ce4632bc337d4aac +https://conda.anaconda.org/anaconda/win-64/xerces-c-3.3.0-hd7fb8db_1.tar.bz2#1f5ecab6231f50437824ebdb1546eefb +https://conda.anaconda.org/anaconda/win-64/aws-c-cal-0.9.13-h630b2a1_0.tar.bz2#eede58e534c721ca775903bc07f2e3ad +https://conda.anaconda.org/anaconda/win-64/aws-c-compression-0.3.2-h1c6eee0_0.tar.bz2#b10d7f89241f025eaf47fbbf8f889c35 +https://conda.anaconda.org/anaconda/win-64/aws-c-sdkutils-0.2.4-h02ab6af_2.tar.bz2#90041ed93c1b4f9918bd997df601abb7 +https://conda.anaconda.org/anaconda/win-64/aws-checksums-0.2.10-h1c6eee0_0.tar.bz2#87cf1e499fcd89761f5382f0e1b3cd55 +https://conda.anaconda.org/anaconda/win-64/expat-2.8.1-hd7fb8db_1.tar.bz2#3e1ec195201f2bd0bf400c727b21ac38 +https://conda.anaconda.org/anaconda/win-64/glog-0.7.1-h5c3225e_0.tar.bz2#ea83cfbbaa22e8d33866476fca104ad8 +https://conda.anaconda.org/anaconda/win-64/glslang-15.3.0-h24d2405_1.tar.bz2#614a06bce9e09e05540b43a6ed0f3024 +https://conda.anaconda.org/conda-forge/win-64/khronos-opencl-icd-loader-2024.10.24-h2466b09_1.conda#71a72eb0eed16a4a76fd88359be48fec +https://conda.anaconda.org/conda-forge/win-64/libamd-3.3.3-h60129d2_7100102.conda#6e5b70d3f9ca3453f55ddd6082dfdf64 +https://conda.anaconda.org/anaconda/win-64/libavif-1.3.0-h87e066f_1.tar.bz2#9da36f45c928375ca47075e40950389d +https://conda.anaconda.org/anaconda/win-64/libbrotlidec-1.2.0-h02c67a5_0.tar.bz2#61b8956b21e5bbd7e7e703c7ce1cb78a +https://conda.anaconda.org/anaconda/win-64/libbrotlienc-1.2.0-h483e6b9_0.tar.bz2#055a7403502e6d367b4061cee8f6476a +https://conda.anaconda.org/conda-forge/win-64/libbtf-2.3.2-h8c1c262_7100102.conda#2d0e918667b02ca4d2e323f6cdc26795 +https://conda.anaconda.org/conda-forge/win-64/libcamd-3.3.3-h8c1c262_7100102.conda#d236ed8f95bb2448b4b3ef16aec17ba9 +https://conda.anaconda.org/conda-forge/win-64/libccolamd-3.3.4-h8c1c262_7100102.conda#eab3eb47c02d7415c531e488a23d76a7 +https://conda.anaconda.org/conda-forge/win-64/libcolamd-3.3.4-h8c1c262_7100102.conda#d0d065d0e1813889373121b78309f609 +https://conda.anaconda.org/conda-forge/win-64/libcxsparse-4.4.1-h8c1c262_7100102.conda#354d160465fffe2b9e58eb6b0fdec9b2 +https://conda.anaconda.org/conda-forge/win-64/libintl-0.22.5-h5728263_3.conda#2cf0cf76cc15d360dfa2f17fd6cf9772 +https://conda.anaconda.org/conda-forge/win-64/libkml-1.3.0-h68a222c_1023.conda#b44e0d9eb84c6c086d809787aab0a1da +https://conda.anaconda.org/conda-forge/win-64/libldl-3.3.2-h8c1c262_7100102.conda#aa6f0dc574ad1ab6bc1a9b0d0f834b11 +https://conda.anaconda.org/conda-forge/win-64/liblzma-devel-5.8.3-hfd05255_0.conda#7845201435d0c7c0a02269c2742da1cc +https://conda.anaconda.org/conda-forge/win-64/libpng-1.6.58-h7351971_0.conda#52f1280563f3b48b5f75414cd2d15dd1 +https://conda.anaconda.org/conda-forge/win-64/librbio-4.3.4-h8c1c262_7100102.conda#ddd22f5e2e251abe7c3394e010856f4d +https://conda.anaconda.org/conda-forge/win-64/librttopo-1.1.0-haa95264_20.conda#7eeb5aed49853f8b3e1ca0463ef55a8e +https://conda.anaconda.org/conda-forge/win-64/libssh2-1.11.1-h9aa295b_0.conda#9dce2f112bfd3400f4f432b3d0ac07b2 +https://conda.anaconda.org/anaconda/win-64/libtheora-1.2.0-habf309d_1.tar.bz2#322774c1cd42afda2ef634d997f62e50 +https://conda.anaconda.org/anaconda/win-64/libvorbis-1.3.7-he912a1a_1.tar.bz2#ff03c292d101a2636bdfd2aefd3bd343 +https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.14.6-h3cfd58e_3.conda#7e26d43d6332a42cb31df19150aa4601 +https://conda.anaconda.org/anaconda/win-64/mpfr-4.2.1-h56c3642_0.tar.bz2#deec9f38ea52c09d8ede5b78869e2829 +https://conda.anaconda.org/conda-forge/win-64/openblas-0.3.33-pthreads_h4a7f399_0.conda#832c0d489b91b7a8756107727b8d6cd7 +https://conda.anaconda.org/conda-forge/win-64/pcre2-10.47-hd2b5f0e_0.conda#77eaf2336f3ae749e712f63e36b0f0a1 +https://conda.anaconda.org/conda-forge/win-64/python-3.10.20-hc20f281_1_cpython.conda#62018eccb570c1fb288b550f804fb940 +https://conda.anaconda.org/conda-forge/win-64/xz-tools-5.8.3-hfd05255_0.conda#01b20ff45704b888d218f31a3aa3ea2a +https://conda.anaconda.org/anaconda/win-64/zlib-1.3.2-h1c6eee0_0.tar.bz2#9b1bb3424d66f91cade684488b3bb932 +https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda#053b84beec00b71ea8ff7a4f84b55207 +https://conda.anaconda.org/anaconda/win-64/aws-c-io-0.26.3-h1c6eee0_0.tar.bz2#15b5e53ccc0f8be4f31a249277cc5f21 +https://conda.anaconda.org/conda-forge/win-64/blosc-1.21.6-hfd34d9b_1.conda#357d7be4146d5fec543bfaa96a8a40de +https://conda.anaconda.org/anaconda/win-64/freetype-2.14.1-hfbffc0b_0.tar.bz2#a6656c4ee4d4bce4c1907749cc37a58e +https://conda.anaconda.org/anaconda/win-64/libcurl-8.20.0-h9440e04_1.tar.bz2#eac639cd372d59d52a222d0ebbab6d37 +https://conda.anaconda.org/conda-forge/win-64/libfreetype6-2.14.1-hdbac1cb_0.conda#6e7c5c5ab485057b5d07fd8188ba5c28 +https://conda.anaconda.org/conda-forge/win-64/libglib-2.88.1-h7ce1215_2.conda#5fb838786a8317ebb38056bbe236d3ff +https://conda.anaconda.org/conda-forge/win-64/libjxl-0.11.2-h932607e_1.conda#327bce3eb1ef1875c7145e915d25bcd3 +https://conda.anaconda.org/anaconda/win-64/libprotobuf-6.33.5-h65d7223_0.tar.bz2#ef481cf1bc2ef7d46756ff91a5b2f7e8 +https://conda.anaconda.org/conda-forge/win-64/libspex-3.2.3-h2f847cc_7100102.conda#8736b72696e63c678b5207064a9fe6f0 +https://conda.anaconda.org/conda-forge/win-64/libtiff-4.7.1-h8f73337_1.conda#549845d5133100142452812feb9ba2e8 +https://conda.anaconda.org/conda-forge/win-64/libxml2-2.14.6-h779ef1b_3.conda#4e4feab78252669572eb1b01076d3425 +https://conda.anaconda.org/anaconda/win-64/ninja-1.13.1-haa95532_0.tar.bz2#7c5b40c1d400c107981c7454c13408d3 +https://conda.anaconda.org/anaconda/win-64/packaging-26.0-py310haa95532_0.tar.bz2#5e761f8fc697c85eef42fc81e27f556e +https://conda.anaconda.org/anaconda/win-64/setuptools-82.0.1-py310haa95532_0.tar.bz2#bc4101e95bafb8b16eb98864ab9a9410 +https://conda.anaconda.org/anaconda/win-64/shaderc-2025.5-hb3e6fc5_1.tar.bz2#13df0bbb89331641c83d9d490276a8b8 +https://conda.anaconda.org/conda-forge/win-64/xz-5.8.3-hb6c8415_0.conda#46f70d503d68c55c104a564928a39852 +https://conda.anaconda.org/anaconda/win-64/aws-c-http-0.10.13-h1c6eee0_0.tar.bz2#4cb642c28f73e21263981c5a08b5845d +https://conda.anaconda.org/conda-forge/win-64/cmake-3.31.8-hdcbee5b_0.conda#96afa0e05c4a683b1c3de91b0259b235 +https://conda.anaconda.org/anaconda/win-64/fontconfig-2.17.1-hdacb8fd_0.tar.bz2#bdfc7707fe70b1c12c04e0e9d83c3b93 +https://conda.anaconda.org/anaconda/win-64/glib-tools-2.86.3-h3823947_0.tar.bz2#7cf659fa530d4941787f626c8a485cba +https://conda.anaconda.org/anaconda/win-64/gstreamer-1.28.4-h3e520db_0.tar.bz2#ea8d595072bb3b1bb0e00732af84235d +https://conda.anaconda.org/anaconda/win-64/lcms2-2.19.1-h5da84a9_0.tar.bz2#b2359caa30d98af20fb53ac5b6ccb97b +https://conda.anaconda.org/conda-forge/win-64/libarchive-3.8.8-gpl_he24518a_100.conda#e61cb5e50e73c4563c2427de40435171 +https://conda.anaconda.org/conda-forge/win-64/libfreetype-2.14.1-h57928b3_0.conda#3235024fe48d4087721797ebd6c9d28c +https://conda.anaconda.org/anaconda/win-64/libhwloc-2.13.0-default_ha975ec4_1001.tar.bz2#d07ea1eb14fa2cbfbb12e56185ea8952 +https://conda.anaconda.org/conda-forge/win-64/libxml2-devel-2.14.6-h779ef1b_3.conda#a0ff38e2b6f27ecad24d94b29d4c58c4 +https://conda.anaconda.org/anaconda/win-64/minizip-4.2.1-hfdc47e4_0.tar.bz2#7b58eb091bc786c1735f89a3ccef9c00 +https://conda.anaconda.org/conda-forge/win-64/proj-9.8.1-hd30e2cd_0.conda#bbf5692c63b2f280975b3430ed3e37fd +https://conda.anaconda.org/anaconda/win-64/wheel-0.47.0-py310haa95532_0.tar.bz2#1b23002e0f677c68b05c1db0ef902792 +https://conda.anaconda.org/anaconda/win-64/aws-c-auth-0.10.1-h1c6eee0_0.tar.bz2#fd98d93d4db92202cf025470c09fa93f +https://conda.anaconda.org/conda-forge/win-64/cairo-1.18.4-h477c42c_1.conda#52ea1beba35b69852d210242dd20f97d +https://conda.anaconda.org/anaconda/win-64/freexl-2.0.0-hd7a5696_0.tar.bz2#4ee1c7ba72a00662bbf30c98d7c7f71f +https://conda.anaconda.org/anaconda/win-64/glib-2.86.3-h60240c0_0.tar.bz2#8f9846b78025a0ca4f5b78fa30ece4d9 +https://conda.anaconda.org/anaconda/win-64/gst-plugins-base-1.28.4-hc29fa2e_0.tar.bz2#e9fab71cd153392d05ddf13d69f35c13 +https://conda.anaconda.org/anaconda/win-64/openjpeg-2.5.4-h56d5a42_1.tar.bz2#e47e5d35e4a2dd70df98649db0d8394e +https://conda.anaconda.org/anaconda/noarch/pip-26.1.2-pyhc872135_0.tar.bz2#0a548b658ae0d1daf6edeed3853a1e08 +https://conda.anaconda.org/anaconda/win-64/tbb-2023.0.0-hf7241d1_0.tar.bz2#6df2d10e045b0ffd5d05e6f111f96a52 +https://conda.anaconda.org/anaconda/win-64/aws-c-s3-0.12.0-h1c6eee0_1.tar.bz2#bbbce54c6bc537c0f0e599b9720c3447 +https://conda.anaconda.org/anaconda/win-64/gdk-pixbuf-2.44.6-hd37cd66_1.tar.bz2#b67ffec729ea22733a0d650b31fc1d4c +https://conda.anaconda.org/anaconda/win-64/harfbuzz-12.3.0-h807c389_2.tar.bz2#6b3c2dfe55567487784f599c3947ba09 +https://conda.anaconda.org/conda-forge/win-64/libspatialite-5.1.0-gpl_h2fc3b25_120.conda#effdac4d017409986c5861d19775eef1 +https://conda.anaconda.org/conda-forge/win-64/mkl-2026.0.0-hac47afa_908.conda#36ea6e1292e9d5e89374201da79646ef +https://conda.anaconda.org/anaconda/win-64/hdf5-2.0.0-h397322d_3.tar.bz2#71a2abe2c10533bd72dc4464192e6e41 +https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-8_h8455456_mkl.conda#4a0ce24b1a946ff77ae9eaa7ef015a33 +https://conda.anaconda.org/conda-forge/win-64/libgdal-core-3.13.1-h9ea07af_2.conda#4a304d2b32076283a41362e486b129fb +https://conda.anaconda.org/anaconda/win-64/pango-1.56.4-h55601a1_0.tar.bz2#182647518397d92f9102ee758ae33284 +https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-8_h2a3cdd5_mkl.conda#09f1d8e4d2675d34ad2acb115211d10c +https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-8_hf9ab0e9_mkl.conda#d584799b920ecae9b75a2b70743a3de7 +https://conda.anaconda.org/anaconda/win-64/librsvg-2.62.1-hc9ec8cf_0.tar.bz2#a3cc320e11cfe6d797d260e7f946b0cf +https://conda.anaconda.org/anaconda/win-64/ffmpeg-8.1.2-h9f2570d_0.tar.bz2#0982129a0ecb29d804c0a433c7fbe3a4 +https://conda.anaconda.org/conda-forge/win-64/libcholmod-5.3.1-hdf2ebef_7100102.conda#5fbe879c0045251c0496a69afb48f6a5 +https://conda.anaconda.org/conda-forge/win-64/numpy-2.2.6-py310h4987827_0.conda#d2596785ac2cf5bab04e2ee9e5d04041 +https://conda.anaconda.org/conda-forge/win-64/gdal-3.13.1-np2py310h6b240f3_2.conda#dd0fbccee1a43cfa7b80a8893d003cf6 +https://conda.anaconda.org/conda-forge/win-64/libklu-2.3.5-h77a2eaa_7100102.conda#981ca310c30ddbe864a37a159fceb3fc +https://conda.anaconda.org/conda-forge/win-64/libspqr-4.3.4-h60c7c62_7100102.conda#85bd703685e5ff50629fe6c0dfd3157e +https://conda.anaconda.org/conda-forge/win-64/libumfpack-6.3.5-h4ca129d_7100102.conda#e970ea4aee9834bf54278c4ca5744a02 +https://conda.anaconda.org/anaconda/win-64/opencv-4.13.0-headless_py310h3a4c6fe_8.tar.bz2#4ebfdd7383794b0079e4951f47875bfc +https://conda.anaconda.org/anaconda/win-64/libopencv-4.13.0-headless_py310h2bad04d_8.tar.bz2#f621c204f65ed160bf3905af5ff35edf +https://conda.anaconda.org/conda-forge/win-64/libparu-1.0.0-hd80212b_7100102.conda#cb99a7bcbd22bbbf3d765702d431beb5 +https://conda.anaconda.org/anaconda/win-64/py-opencv-4.13.0-headless_py310h4d12c21_8.tar.bz2#c2e0870c5a2fb8fe2b95a03fe1722314 +https://conda.anaconda.org/conda-forge/win-64/suitesparse-7.10.1-hfa24a04_7100102.conda#22f7b99d3a3687d1e24466858098d3ac +https://conda.anaconda.org/conda-forge/win-64/ceres-solver-2.2.0-cpugplh526ae33_211.conda#764371437124318871f9e5004352d151 diff --git a/conda.yml b/conda.yml new file mode 100644 index 000000000..337d19964 --- /dev/null +++ b/conda.yml @@ -0,0 +1,24 @@ +name: opensfm +dependencies: + - python=3.10 + - pip + - cmake=3.31 + - ninja + - make + - libxcrypt # [linux] [osx] + - libopencv<5.0 *=headless* + - py-opencv<5.0 + - anaconda::metis + - conda-forge::suitesparse=7.10.* + - conda-forge::llvm-openmp + - conda-forge::cxx-compiler + - conda-forge::openblas + - conda-forge::gperftools # [linux] + - conda-forge::ceres-solver=2.2 + - conda-forge::fast_float + - conda-forge::gdal + - conda-forge::laszip + - conda-forge::libvorbis # [osx] + - conda-forge::ocl-icd # [linux] + - conda-forge::opencl-headers + - conda-forge::khronos-opencl-icd-loader # [win] diff --git a/configs/aerial.yaml b/configs/aerial.yaml new file mode 100644 index 000000000..f77b08eb8 --- /dev/null +++ b/configs/aerial.yaml @@ -0,0 +1,105 @@ + +processes: 24 + +################################## +# Params for metadata +################################## +# Default projection type to use when it cannot be inferred from EXIF metadata +default_projection_type: "brown" + +################################## +# Params for features +################################## +# If fewer frames are detected, sift_peak_threshold/surf_hessian_threshold is reduced. +feature_min_frames: 20000 +# Resize the image if its size is larger than specified. Set to -1 for original size +feature_process_size: 4096 + + +################################## +# Params for matching +################################## +matching_gps_distance: 0 +# Number of images to match selected by time taken. Set to 0 to disable +matching_time_neighbors: 6 +# Number of images to match selected by image name. Set to 0 to disable +matching_order_neighbors: 6 + +# Number of images to match selected by VLAD distance. Set to 0 to disable +matching_vlad_neighbors: 8 +# Maximum GPS distance for preempting images before using selection by VLAD distance. Set to 0 to disable +matching_vlad_gps_distance: 50 +# Number of images (selected by GPS distance) to preempt before using selection by VLAD distance. Set to 0 to use no limit (or disable if matching_vlad_gps_distance is also 0) +matching_vlad_gps_neighbors: 20 +# If True, VLAD image selection will use N neighbors from the same camera + N neighbors from any different camera. If False, the selection will take the nearest neighbors from all cameras. +matching_vlad_other_cameras: false +# Number of rounds to run when running triangulation-based pair selection +matching_graph_rounds: 20 + + +################################## +# Params for track creation +################################## +# Minimum number of features/images per track +min_track_length: 2 + +################################## +# Params for bundle adjustment +################################## +# Type of threshold for filtering outlier : either fixed value (FIXED) or based on actual distribution (AUTO) +bundle_outlier_filtering_type: "FIXED" +# For AUTO filtering type, projections with larger reprojection than ratio-times-mean, are removed +bundle_outlier_auto_ratio: 3.0 +# For FIXED filtering type, projections with larger reprojection error after bundle adjustment are removed +bundle_outlier_fixed_threshold: 0.006 +# Optimize internal camera parameters during bundle +optimize_camera_parameters: true +# Maximum optimizer iterations. +bundle_max_iterations: 100 + +# Retriangulate all points from time to time +retriangulation: true +# Retriangulate when the number of points grows by this ratio +retriangulation_ratio: 1.2 +# Use analytic derivatives or auto-differentiated ones during bundle adjustment +bundle_analytic_derivatives: true +# Bundle when the number of points grows by this ratio +bundle_new_points_ratio: 1.2 +# Minimum number of common points betwenn images to be considered neighbors +local_bundle_min_common_points: 20 +# Max number of shots to optimize during local bundle adjustment +local_bundle_max_shots: 10 +# Remove uncertain and isolated points from the final point cloud +filter_final_point_cloud: True + +################################## +# Params for GPS alignment +################################## +# Use or ignore EXIF altitude tag +use_altitude_tag: true +# orientation_prior or naive +align_method: "auto" +# orientation_prior or naive +align_orientation_prior: "vertical" +# Enforce GPS position in bundle adjustment +bundle_use_gps: true +# Enforce Ground Control Point position in bundle adjustment +bundle_use_gcp: true +# Compensate GPS with a per-camera similarity transform +bundle_compensate_gps_bias: true +# Annealing schedule for GCP weights: list of multipliers applied to gcp_global_weight +# across successive bundle passes. Set to [1.0] to disable annealing (single pass). +gcp_annealing_steps: [5.0, 25.0] + +################################## +# Params for Dense Reconstruction +################################## +depthmap_checkerboard_filter: False +# Minimum connected component size in pixels; smaller segments are removed as speckle noise +depthmap_speckle_min_size: 0 +# Maximum gap size in pixels for linear depth interpolation (0 = disabled) +depthmap_gap_max_size: 0 +# Weight for geometric consistency cost (0 = disabled). Applied per source view. +depthmap_geom_consistency_weight: 0.05 +# Use SfM points to seed a Delaunay planar prior before PatchMatch iterations +depthmap_sfm_planar_prior: False \ No newline at end of file diff --git a/configs/object.yaml b/configs/object.yaml new file mode 100644 index 000000000..0555ef694 --- /dev/null +++ b/configs/object.yaml @@ -0,0 +1,109 @@ + +processes: 24 + +################################## +# Params for metadata +################################## +# Default projection type to use when it cannot be inferred from EXIF metadata +default_projection_type: "brown" + +################################## +# Params for features +################################## +# If fewer frames are detected, sift_peak_threshold/surf_hessian_threshold is reduced. +feature_min_frames: 20000 +# Resize the image if its size is larger than specified. Set to -1 for original size +feature_process_size: 4096 + + +################################## +# Params for matching +################################## +matching_gps_distance: 0 +# Number of images to match selected by time taken. Set to 0 to disable +matching_time_neighbors: 8 +# Number of images to match selected by image name. Set to 0 to disable +matching_order_neighbors: 8 + +# Number of images to match selected by VLAD distance. Set to 0 to disable +matching_vlad_neighbors: 10 +# Maximum GPS distance for preempting images before using selection by VLAD distance. Set to 0 to disable +matching_vlad_gps_distance: 0 +# Number of images (selected by GPS distance) to preempt before using selection by VLAD distance. Set to 0 to use no limit (or disable if matching_vlad_gps_distance is also 0) +matching_vlad_gps_neighbors: 0 +# If True, VLAD image selection will use N neighbors from the same camera + N neighbors from any different camera. If False, the selection will take the nearest neighbors from all cameras. +matching_vlad_other_cameras: false +# Number of rounds to run when running triangulation-based pair selection +matching_graph_rounds: 0 + + +################################## +# Params for track creation +################################## +# Minimum number of features/images per track +min_track_length: 2 + +################################## +# Params for bundle adjustment +################################## +# Type of threshold for filtering outlier : either fixed value (FIXED) or based on actual distribution (AUTO) +bundle_outlier_filtering_type: "FIXED" +# For AUTO filtering type, projections with larger reprojection than ratio-times-mean, are removed +bundle_outlier_auto_ratio: 3.0 +# For FIXED filtering type, projections with larger reprojection error after bundle adjustment are removed +bundle_outlier_fixed_threshold: 0.006 +# Optimize internal camera parameters during bundle +optimize_camera_parameters: true +# Maximum optimizer iterations. +bundle_max_iterations: 100 + +# Retriangulate all points from time to time +retriangulation: true +# Retriangulate when the number of points grows by this ratio +retriangulation_ratio: 1.2 +# Use analytic derivatives or auto-differentiated ones during bundle adjustment +bundle_analytic_derivatives: true +# Bundle when the number of points grows by this ratio +bundle_new_points_ratio: 1.2 +# Minimum number of common points betwenn images to be considered neighbors +local_bundle_min_common_points: 20 +# Max number of shots to optimize during local bundle adjustment +local_bundle_max_shots: 10 +# Remove uncertain and isolated points from the final point cloud +filter_final_point_cloud: True + +################################## +# Params for GPS alignment +################################## +# Use or ignore EXIF altitude tag +use_altitude_tag: true +# orientation_prior or naive +align_method: "auto" +# orientation_prior or naive +align_orientation_prior: "horizontal" +# Enforce GPS position in bundle adjustment +bundle_use_gps: true +# Enforce Ground Control Point position in bundle adjustment +bundle_use_gcp: true +# Compensate GPS with a per-camera similarity transform +bundle_compensate_gps_bias: true +# Annealing schedule for GCP weights: list of multipliers applied to gcp_global_weight +# across successive bundle passes. Set to [1.0] to disable annealing (single pass). +gcp_annealing_steps: [5.0, 25.0] + +################################## +# Params for Dense Reconstruction +################################## +depthmap_checkerboard_filter: True +# Minimum connected component size in pixels; smaller segments are removed as speckle noise +depthmap_speckle_min_size: 100 +# Maximum gap size in pixels for linear depth interpolation (0 = disabled) +depthmap_gap_max_size: 15 +# Weight for geometric consistency cost (0 = disabled). Applied per source view. +depthmap_geom_consistency_weight: 0.05 +# Use SfM points to seed a Delaunay planar prior before PatchMatch iterations +depthmap_sfm_planar_prior: True +# Photometric TSDF refinement +depthmap_fusion_svo_refine_enabled: True +# Controls DSM + ortho rendering +dsm_enabled: False \ No newline at end of file diff --git a/configs/terrestrial.yaml b/configs/terrestrial.yaml new file mode 100644 index 000000000..a5258ede2 --- /dev/null +++ b/configs/terrestrial.yaml @@ -0,0 +1,106 @@ + +processes: 24 + +################################## +# Params for metadata +################################## +# Default projection type to use when it cannot be inferred from EXIF metadata +default_projection_type: "brown" + +################################## +# Params for features +################################## +# If fewer frames are detected, sift_peak_threshold/surf_hessian_threshold is reduced. +feature_min_frames: 20000 +# Resize the image if its size is larger than specified. Set to -1 for original size +feature_process_size: 4096 + + +################################## +# Params for matching +################################## +matching_gps_distance: 0 +# Number of images to match selected by time taken. Set to 0 to disable +matching_time_neighbors: 10 +# Number of images to match selected by image name. Set to 0 to disable +matching_order_neighbors: 10 + +# Number of images to match selected by VLAD distance. Set to 0 to disable +matching_vlad_neighbors: 8 +# Maximum GPS distance for preempting images before using selection by VLAD distance. Set to 0 to disable +matching_vlad_gps_distance: 50 +# Number of images (selected by GPS distance) to preempt before using selection by VLAD distance. Set to 0 to use no limit (or disable if matching_vlad_gps_distance is also 0) +matching_vlad_gps_neighbors: 20 +# If True, VLAD image selection will use N neighbors from the same camera + N neighbors from any different camera. If False, the selection will take the nearest neighbors from all cameras. +matching_vlad_other_cameras: false +# Number of rounds to run when running triangulation-based pair selection +matching_graph_rounds: 20 + + +################################## +# Params for track creation +################################## +# Minimum number of features/images per track +min_track_length: 2 + +################################## +# Params for bundle adjustment +################################## +# Type of threshold for filtering outlier : either fixed value (FIXED) or based on actual distribution (AUTO) +bundle_outlier_filtering_type: "FIXED" +# For AUTO filtering type, projections with larger reprojection than ratio-times-mean, are removed +bundle_outlier_auto_ratio: 3.0 +# For FIXED filtering type, projections with larger reprojection error after bundle adjustment are removed +bundle_outlier_fixed_threshold: 0.006 +# Optimize internal camera parameters during bundle +optimize_camera_parameters: true +# Maximum optimizer iterations. +bundle_max_iterations: 100 + +# Retriangulate all points from time to time +retriangulation: true +# Retriangulate when the number of points grows by this ratio +retriangulation_ratio: 1.2 +# Use analytic derivatives or auto-differentiated ones during bundle adjustment +bundle_analytic_derivatives: true +# Bundle when the number of points grows by this ratio +bundle_new_points_ratio: 1.2 +# Minimum number of common points betwenn images to be considered neighbors +local_bundle_min_common_points: 20 +# Max number of shots to optimize during local bundle adjustment +local_bundle_max_shots: 10 +# Remove uncertain and isolated points from the final point cloud +filter_final_point_cloud: True + +################################## +# Params for GPS alignment +################################## +# Use or ignore EXIF altitude tag +use_altitude_tag: true +# orientation_prior or naive +align_method: "auto" +# orientation_prior or naive +align_orientation_prior: "horizontal" +# Enforce GPS position in bundle adjustment +bundle_use_gps: true +# Enforce Ground Control Point position in bundle adjustment +bundle_use_gcp: true +# Compensate GPS with a per-camera similarity transform +bundle_compensate_gps_bias: true +# Annealing schedule for GCP weights: list of multipliers applied to gcp_global_weight +# across successive bundle passes. Set to [1.0] to disable annealing (single pass). +gcp_annealing_steps: [1.0, 10.0] + + +################################## +# Params for Dense Reconstruction +################################## +depthmap_checkerboard_filter: True +# Minimum connected component size in pixels; smaller segments are removed as speckle noise +depthmap_speckle_min_size: 100 +# Maximum gap size in pixels for linear depth interpolation (0 = disabled) +depthmap_gap_max_size: 15 +# Weight for geometric consistency cost (0 = disabled). Applied per source view. +depthmap_geom_consistency_weight: 0.05 +# Use SfM points to seed a Delaunay planar prior before PatchMatch iterations +depthmap_sfm_planar_prior: True \ No newline at end of file diff --git a/data/berlin/config.yaml b/data/berlin/config.yaml index d4689cc5e..c9249183b 100644 --- a/data/berlin/config.yaml +++ b/data/berlin/config.yaml @@ -1,4 +1,3 @@ - # OpenSfM will use the default parameters from opensfm/config.py # Set here any parameter that you want to override for this dataset # For example: diff --git a/data/lund/config.yaml b/data/lund/config.yaml index 5d2e0359f..6cfded297 100644 --- a/data/lund/config.yaml +++ b/data/lund/config.yaml @@ -1,4 +1,3 @@ - # OpenSfM will use the default parameters from opensfm/config.py # Set here any parameter that you want to override for this dataset # For example: diff --git a/doc/benchmark.md b/doc/benchmark.md new file mode 100644 index 000000000..f23a049cd --- /dev/null +++ b/doc/benchmark.md @@ -0,0 +1,184 @@ +# Benchmarking + +The `benchmark/` module runs the OpenSfM pipeline on a fixed set of datasets **at a specific git commit**, collects per-step timings and quality statistics, and produces an HTML report that **compares one commit against another**. It's the tool to answer "did my change make things faster / more accurate / break anything?". + +## How it works + +Each benchmark run is fully isolated from your working tree: + +1. **Resolve the commit** you ask for (hash, branch, tag, or `HEAD`). +2. **Check it out in a throwaway git worktree** under `.benchmark-worktrees//` — your current checkout is never touched. +3. **Build it in a dedicated conda env** (`opensfm-bench-`) created from the commit's own lock file (`conda--.lock`, falling back to `conda.yml`), so the binary matches that commit exactly. +4. **Set up each dataset** as a lightweight directory (an `image_list.txt` of absolute paths to the source images, ancillary files copied, and the chosen [workflow config](using.md#workflow-presets-configs) installed with `processes` set to the machine's CPU count). +5. **Run the pipeline** step by step, timing each one. +6. **Generate `comparison.html`**, diffing this run's metrics against a reference run. +7. **Tear down** the worktree and conda env. + +Results land in `benchmark_runs/_/`. Both `.benchmark-worktrees/` and `benchmark_runs/` are git-ignored. + +> The orchestrator also hardens itself against the Linux OOM killer: it lowers its own OOM score and raises the pipeline subprocesses', so under memory pressure the kernel kills a *pipeline step* (recoverable with `--resume`) rather than the whole benchmark. + +## Prerequisites + +- A **git checkout** of the repo (worktrees require it — a source tarball won't work) and `conda` on `PATH`. +- A **datasets root**: a directory with one subfolder per dataset, each containing an `images/` folder. Optional per-dataset extras are picked up automatically: `gcp_list.txt`, `ground_control_points.json`, `camera_models_overrides.json`, `exif_overrides.json`, and a `masks/` folder. + +## The config file + +A small JSON file describes what to run. See [`benchmark/benchmark_example.json`](../benchmark/benchmark_example.json): + +```json +{ + "root": "./data", + "datasets": { + "berlin": "terrestrial", + "lund": "terrestrial" + }, + "output_dir": "./benchmark_runs" +} +``` + +| Field | Meaning | +| ----- | ------- | +| `root` | Directory containing the dataset subfolders. Must exist. | +| `datasets` | Map of `dataset_name → workflow config`. The config name resolves to [`configs/.yaml`](using.md#workflow-presets-configs) (`aerial`, `terrestrial`, `object`). | +| `output_dir` | Where run directories are written (default `./benchmark_runs`). | + +The config is validated up front: the root, every dataset folder, every `images/` subfolder, and every referenced `configs/.yaml` must exist. + +## Running a benchmark + +The module is invoked with `python -m benchmark.run`. The simplest run benchmarks a commit: + +```bash +python -m benchmark.run --config benchmark/benchmark_example.json --commit HEAD +``` + +Add `--dense` to also run the dense stages so their timings appear in the report: + +```bash +python -m benchmark.run --config benchmark/benchmark_example.json --commit HEAD --dense +``` + +Useful flags: + +| Flag | Purpose | +| ---- | ------- | +| `--commit ` | Commit/branch/tag to benchmark (starts a new run). Mutually exclusive with `--resume`. | +| `--resume ` | Resume or re-run an existing run directory. | +| `--dense` | Also run `undistort` + the dense stages before `compute_statistics`. | +| `--from-step ` | Start at this step (see [FAQ](#faq--troubleshooting)). | +| `--reference ` | What to compare against (default: most recent other run). | +| `--bootstrap ` | With `--commit --from-step`, reuse earlier-step outputs from this run. | +| `--local-staging` / `--scratch-dir ` | Process on a local disk and mirror back (for NAS run dirs). | +| `--report-only` | Regenerate the HTML only (requires `--resume`). | +| `--output-dir ` | Override `output_dir` (ignored with `--resume`). | +| `-v` / `--verbose` | Debug logging. | + +## Comparing results between commits + +The typical A/B workflow is two runs — a baseline and your change: + +```bash +# 1. Baseline (e.g. master) +python -m benchmark.run --config cfg.json --commit master + +# 2. Your feature branch, compared explicitly against the baseline +python -m benchmark.run --config cfg.json --commit my-feature --reference master +``` + +`--reference` accepts either a **run directory** or a **commit-hash prefix** (it searches `output_dir` for a matching run). If you omit it, the most recent other run is used automatically — so simply running two commits back-to-back already produces a comparison. + +Open `benchmark_runs//comparison.html`. Each dataset shows a `reference` row and a `current` row across several metric groups, with cells coloured **green (better)** / **red (worse)**: + +- **Reconstruction Summary** — components, reconstructed shots/points, observations +- **Reprojection Errors** — normalized / pixel / angular +- **Track Statistics** — average track length (and length > 2) +- **Feature Statistics** — detected & reconstructed features (mean/median) +- **GPS Errors** / **GCP Errors** — average error +- **Processing Times** — per-step wall times (plus the dense stages when either run used `--dense`), and total time + +A dataset that failed shows a **FAILED** banner with the step it died on; the dataset header links to that dataset's `stats/report.pdf`. All numbers are read from each dataset's `stats/stats.json` (produced by `compute_statistics`); see [quality report](quality_report.md) for what they mean. + +## Output layout + +``` +benchmark_runs/ +└── a1b2c3d4_20260628_124501/ # _ + ├── run_meta.json # commit, status, per-dataset per-step timings + success/stderr + ├── comparison.html # the A/B report + ├── berlin/ # a lightweight dataset dir (image_list.txt, config.yaml, + │ ├── ... # exif/, features/, reconstruction.json, reports/, stats/ …) + │ └── stats/{stats.json,report.pdf} + └── lund/ + └── ... +``` + +`run_meta.json` is written **after every dataset** (and updated as steps complete), which is what makes a run crash-safe and resumable. + +## Pipeline steps + +SfM (always): `extract_metadata` → `detect_features` → `match_features` → `create_tracks` → `reconstruct` → `compute_statistics` → `export_report`. + +Dense (with `--dense`, spliced in before `compute_statistics`): `undistort` → `dense_clustering` → `dense_equalize` → `compute_depthmaps` → `fuse_depthmaps` → `dense_merging` (run with `--georeferenced`). See [dense reconstruction](dense.md). + +## FAQ / troubleshooting + +### The benchmark crashed or was interrupted — how do I resume? + +Point `--resume` at the run directory. It reads the commit from `run_meta.json` (or infers it from the folder name), then skips every step whose output already exists and continues where it stopped: + +```bash +python -m benchmark.run --config cfg.json --resume benchmark_runs/a1b2c3d4_20260628_124501 +``` + +### How do I re-run from a specific step? + +Use `--from-step` with `--resume`. Steps *before* it are skipped unconditionally; that step and everything after it are re-run **even if their outputs already exist** (use this after changing, say, reconstruction code): + +```bash +python -m benchmark.run --config cfg.json --resume --from-step reconstruct +``` + +Valid steps: `extract_metadata`, `detect_features`, `match_features`, `create_tracks`, `reconstruct`, `undistort`, `dense_clustering`, `dense_equalize`, `compute_depthmaps`, `fuse_depthmaps`, `dense_merging`, `compute_statistics`, `export_report`. + +### How do I regenerate only the metrics HTML (no rebuild, no pipeline)? + +`--report-only` (requires `--resume`) skips the worktree, build and pipeline entirely and just rebuilds `comparison.html` from existing results. Handy after the pipeline finished but you want a different comparison: + +```bash +python -m benchmark.run --config cfg.json --resume --report-only --reference +``` + +### How do I run only SfM, or SfM + dense? + +SfM-only is the default — just omit `--dense`. Add `--dense` for the dense stages too. Selecting a dense `--from-step` (e.g. `compute_depthmaps`) implies `--dense` automatically. On `--resume`, the dense setting is inherited from the resumed run's `run_meta.json` unless you pass `--dense` again. + +### How do I start a fresh run but reuse expensive early steps (features/matches)? + +Combine `--commit` with `--from-step`: outputs of the steps before `--from-step` are symlinked/copied from an existing run. Without `--bootstrap`, the most recent **complete** run for the same commit is auto-detected: + +```bash +# reuse everything up to reconstruct from a prior run of this commit +python -m benchmark.run --config cfg.json --commit my-feature --from-step reconstruct +# ...or name the source explicitly +python -m benchmark.run --config cfg.json --commit my-feature --from-step reconstruct --bootstrap +``` + +### One dataset failed but the others were fine. + +Each dataset is independent: a failure stops *that* dataset at its failing step (recorded in `run_meta.json` as `failed_step` plus the tail of stderr) and the report shows a FAILED banner, but the remaining datasets still run. Re-run with `--resume` to retry just the unfinished steps, or `--resume --from-step ` to force a redo. + +### My timings look I/O-bound (run directory on a NAS). + +Use `--local-staging` (optionally with `--scratch-dir `): each dataset is processed on local scratch and mirrored back to the run directory once, so network I/O doesn't pollute the timed steps. + +### The build fails in the worktree. + +The conda env is created from the *benchmarked commit's* `conda--.lock` (or `conda.yml`). Make sure that commit actually contains a valid lock file / `conda.yml`. Stale worktrees in `.benchmark-worktrees/` are force-removed and recreated each run; you can safely delete that directory between runs. + +## See also + +- [Workflow presets (`configs/`)](using.md#workflow-presets-configs) — the per-dataset configs the benchmark installs. +- [Quality report](quality_report.md) — the metrics surfaced in the comparison. +- [Pipeline commands](using.md) — the steps being benchmarked. diff --git a/doc/building.md b/doc/building.md new file mode 100644 index 000000000..8b103afaf --- /dev/null +++ b/doc/building.md @@ -0,0 +1,77 @@ +# Building OpenSfM + +## Prerequisites + +[Conda](https://docs.conda.io/en/latest/miniconda.html) (or Miniconda) is the only prerequisite on all platforms. The conda environment installs the full toolchain — the C++ compiler (MSVC on Windows, clang/gcc elsewhere), CMake, Ninja, and all library dependencies. + +> The build also needs the bundled `pybind11` git submodule. The `--recursive` clone below fetches it; in an existing clone run `git submodule update --init --recursive`. + +## Installation + +### Linux + +```bash +git clone --recursive https://github.com/OpenSfM/OpenSfM +cd OpenSfM +conda create --name opensfm --file conda-linux-64.lock --yes +conda activate opensfm +pip install -e . +``` + +### macOS (Apple Silicon) + +```bash +git clone --recursive https://github.com/OpenSfM/OpenSfM +cd OpenSfM +conda create --name opensfm --file conda-osx-arm64.lock --yes +conda activate opensfm +pip install -e . +``` + +> Only Apple Silicon (`arm64`) is supported. Intel Macs (`osx-64`) are not supported — there is no lock file for them. + +### Windows + +Install [Miniconda](https://docs.conda.io/en/latest/miniconda.html) first, then run from the *Anaconda Prompt*: + +```bat +git clone --recursive https://github.com/OpenSfM/OpenSfM +cd OpenSfM +conda create --name opensfm --file conda-win-64.lock --yes +conda activate opensfm +pip install -e . +``` + +> The MSVC C++ compiler ships with the environment (`conda-win-64.lock` pins `vs2022_win-64`), so a separate Visual Studio / Build Tools install is **not** required. + +## Running + +Activate the environment, then run the desired pipeline step: + +**Linux / macOS:** + +```bash +conda activate opensfm +./bin/opensfm COMMAND path/to/dataset +``` + +**Windows:** + +```bat +conda activate opensfm +bin\opensfm.bat COMMAND path\to\dataset +``` + +See [using.md](using.md) for the full list of available commands. + +## Viewer + +A web-based 3D viewer is included under `viewer/`. See the [quickstart](quickstart.md#viewer) and [viewer/README.md](../viewer/README.md) for how to serve a dataset. + +## Building Docker Images + +Example Dockerfiles are provided for Ubuntu 20.04 and 24.04: + +```bash +docker build -t opensfm.ubuntu24 -f Dockerfile.ubuntu24 . +``` diff --git a/doc/camera_coordinate_system.md b/doc/camera_coordinate_system.md new file mode 100644 index 000000000..3a499902a --- /dev/null +++ b/doc/camera_coordinate_system.md @@ -0,0 +1,3 @@ +# Camera Coordinate System and Conventions + +This page has been merged into [Geometric Models — Camera Coordinates](geometry.md#camera-coordinates). diff --git a/doc/configuration.md b/doc/configuration.md new file mode 100644 index 000000000..19c8a1e98 --- /dev/null +++ b/doc/configuration.md @@ -0,0 +1,450 @@ +# Configuration Reference + +Pipeline options are set in `config.yaml` at the root of each dataset folder. Any key present overrides the default value defined in the `OpenSfMConfig` dataclass in `opensfm/config.py`. + +Example `config.yaml`: +```yaml +feature_type: SIFT +feature_process_size: 4096 +matching_gps_distance: 200 +processes: 4 +``` + +--- + +## Metadata + +| Parameter | Type | Default | Description | +| ------------------------------------- | ----- | --------------- | ------------------------------------------------------------ | +| `use_exif_size` | bool | `true` | Use image size from EXIF | +| `unknown_camera_models_are_different` | bool | `false` | Treat images from unknown camera models as different cameras | +| `default_projection_type` | str | `"perspective"` | Projection type when EXIF cannot determine it | +| `default_focal_prior` | float | `0.85` | Default focal-length-to-sensor-width ratio | +| `proj_cdn_enabled` | bool | `true` | Download missing datum/geoid grids from the PROJ CDN | +| `proj_grid_cache_dir` | str | `""` | Folder to load/store geoid files and datum grids | + +## Features + +| Parameter | Type | Default | Description | +| ---------------------------------- | ----------- | --------- | ------------------------------------------------------------------------- | +| `feature_type` | str | `"HAHOG"` | Feature detector: `HAHOG`, `SIFT`, `DSPSIFT`, `SURF`, `AKAZE`, `ORB` | +| `feature_root` | bool | `true` | Apply square-root mapping to descriptors | +| `feature_min_frames` | int | `4000` | Target minimum features; detector threshold is relaxed if fewer are found | +| `feature_min_frames_panorama` | int | `16000` | Same as above for panoramas | +| `feature_process_size` | int | `2048` | Resize image (longest side) before extraction. `-1` = original | +| `feature_process_size_panorama` | int | `4096` | Same as above for panoramas | +| `feature_use_adaptive_suppression` | bool | `false` | Use adaptive non-maximal suppression | +| `features_bake_segmentation` | bool | `false` | Bake segmentation class/instance into feature data at extraction time | +| `mem_ceiling` | int\|null | `null` | Max memory (MB) for feature extraction | +| `mem_ratio` | float\|null | `null` | Fraction of `mem_ceiling` to use | + +### SIFT + +| Parameter | Type | Default | Description | +| --------------------- | ----- | ------- | ---------------------------- | +| `sift_peak_threshold` | float | `0.1` | Smaller → more features | +| `sift_edge_threshold` | int | `10` | Edge rejection threshold | +| `sift_nfeatures` | int | `0` | Max features (0 = unlimited) | +| `sift_octave_layers` | int | `3` | Layers per octave | +| `sift_sigma` | float | `1.6` | Initial Gaussian sigma | + +### DSPSIFT + +Domain-size-pooling SIFT — a SIFT variant that pools descriptors across scales for robustness. + +| Parameter | Type | Default | Description | +| ------------------------ | ----- | ------- | ------------------------ | +| `dspsift_peak_threshold` | float | `0.006` | Smaller → more features | +| `dspsift_edge_threshold` | int | `10` | Edge rejection threshold | + +### SURF + +| Parameter | Type | Default | Description | +| ------------------------ | ----- | ------- | --------------------------- | +| `surf_hessian_threshold` | float | `3000` | Smaller → more features | +| `surf_n_octaves` | int | `4` | Number of octaves | +| `surf_n_octavelayers` | int | `2` | Layers per octave | +| `surf_upright` | int | `0` | Compute upright descriptors | + +### AKAZE + +| Parameter | Type | Default | Description | +| ------------------------------- | ----- | --------- | ---------------------------------- | +| `akaze_omax` | int | `4` | Maximum octave evolution | +| `akaze_dthreshold` | float | `0.001` | Detector response threshold | +| `akaze_descriptor` | str | `"MSURF"` | Descriptor type | +| `akaze_descriptor_size` | int | `0` | Descriptor size in bits (0 = full) | +| `akaze_descriptor_channels` | int | `3` | Feature channels (1–3) | +| `akaze_kcontrast_percentile` | float | `0.7` | Contrast percentile | +| `akaze_use_isotropic_diffusion` | bool | `false` | Use isotropic diffusion | + +### HAHOG + +| Parameter | Type | Default | Description | +| -------------------------- | ----- | --------- | -------------------------------------- | +| `hahog_peak_threshold` | float | `0.00001` | Peak threshold | +| `hahog_edge_threshold` | float | `10` | Edge threshold | +| `hahog_normalize_to_uchar` | bool | `true` | Normalize descriptors to unsigned char | + +## Matching + +| Parameter | Type | Default | Description | +| -------------------- | ----- | --------- | --------------------------------- | +| `lowes_ratio` | float | `0.85` | Lowe's ratio test threshold | +| `matcher_type` | str | `"GPU_HAMMING"` | `FLANN`, `BRUTEFORCE`, `WORDS` or `GPU_HAMMING` | +| `symmetric_matching` | bool | `true` | Match in both directions | +| `binary_training_pairs` | int | `100` | Image pairs used to train the binary projection (`GPU_HAMMING`) | + +### FLANN + +| Parameter | Type | Default | Description | +| ------------------ | ---- | ---------- | -------------------------------------- | +| `flann_algorithm` | str | `"KMEANS"` | `KMEANS` or `KDTREE` | +| `flann_branching` | int | `8` | Branching factor | +| `flann_iterations` | int | `10` | K-means iterations | +| `flann_tree` | int | `8` | Number of trees (KDTREE) | +| `flann_checks` | int | `20` | Checks during search. Smaller → faster | + +### Bag-of-Words (BoW) + +| Parameter | Type | Default | Description | +| -------------------- | ---- | ---------------------------------- | ---------------------------- | +| `bow_file` | str | `"bow_hahog_root_uchar_10000.npz"` | Visual vocabulary file | +| `bow_words_to_match` | int | `50` | Words to explore per feature | +| `bow_num_checks` | int | `20` | Matching features to check | +| `bow_matcher_type` | str | `"FLANN"` | Matcher for word assignment | + +### VLAD + +| Parameter | Type | Default | Description | +| ----------- | ---- | ------------------------------- | ------------------ | +| `vlad_file` | str | `"bow_hahog_root_uchar_64.npz"` | VLAD codebook file | + +### Guided Matching + +| Parameter | Type | Default | Description | +| --------------------------------- | ----- | ------- | ---------------------------------------------------- | +| `guided_spanning_trees` | int | `5` | Randomized spanning-tree samples | +| `guided_spanning_trees_random` | float | `0.5` | Random ratio for edge weights | +| `guided_matching_threshold` | float | `0.006` | Epipolar distance threshold (radians) | +| `guided_min_length_initial` | int | `3` | Min track length for initial triangulation | +| `guided_min_length_final` | int | `3` | Min track length for final triangulation | +| `guided_extend_threshold` | float | `0.002` | Reprojection threshold for track extension (radians) | +| `guided_extend_image_neighbors` | int | `50` | Neighbor images considered | +| `guided_extend_feature_neighbors` | int | `10` | Max reprojected neighbors to check | + +### Pair Selection + +| Parameter | Type | Default | Description | +| ----------------------------- | ----- | ------- | ------------------------------------------------ | +| `matching_gps_distance` | float | `150` | Max GPS distance (m) between images for matching | +| `matching_gps_neighbors` | int | `0` | Images selected by GPS distance (0 = no limit) | +| `matching_time_neighbors` | int | `0` | Images selected by capture time (0 = disabled) | +| `matching_order_neighbors` | int | `0` | Images selected by filename order (0 = disabled) | +| `matching_bow_neighbors` | int | `0` | Images selected by BoW distance (0 = disabled) | +| `matching_bow_gps_distance` | float | `0` | GPS preemption radius for BoW (0 = disabled) | +| `matching_bow_gps_neighbors` | int | `0` | GPS-preempted images for BoW (0 = no limit) | +| `matching_bow_other_cameras` | bool | `false` | BoW: N neighbors per camera type | +| `matching_vlad_neighbors` | int | `0` | Images selected by VLAD distance (0 = disabled) | +| `matching_vlad_gps_distance` | float | `0` | GPS preemption radius for VLAD (0 = disabled) | +| `matching_vlad_gps_neighbors` | int | `0` | GPS-preempted images for VLAD (0 = no limit) | +| `matching_vlad_other_cameras` | bool | `false` | VLAD: N neighbors per camera type | +| `matching_graph_rounds` | int | `0` | Triangulation-based pair selection rounds | +| `matching_use_filters` | bool | `false` | Remove static matches via heuristics | +| `matching_use_segmentation` | bool | `false` | Use segmentation to improve matching | +| `matching_use_opk` | bool | `true` | Use orientation (OPK) to improve matching | + +## Geometric Estimation + +| Parameter | Type | Default | Description | +| ------------------------------------- | ----- | -------- | ------------------------------------------------------------------ | +| `robust_matching_threshold` | float | `0.004` | Fundamental matrix outlier threshold (fraction of image width) | +| `robust_matching_calib_threshold` | float | `0.004` | Essential matrix outlier threshold during matching (radians) | +| `robust_matching_min_match` | int | `20` | Minimum matches to accept a pair | +| `five_point_algo_threshold` | float | `0.004` | Essential matrix outlier threshold during reconstruction (radians) | +| `five_point_algo_min_inliers` | int | `20` | Min inliers for valid two-view reconstruction | +| `five_point_refine_match_iterations` | int | `10` | LM iterations for relative pose during matching | +| `five_point_refine_rec_iterations` | int | `1000` | LM iterations for relative pose during reconstruction | +| `five_point_reversal_check` | bool | `false` | Check Necker reversal ambiguity (useful for aerial) | +| `five_point_reversal_ratio` | float | `0.95` | Non-reversed/reversed point ratio threshold | +| `triangulation_threshold` | float | `0.006` | Outlier threshold for triangulated points (radians) | +| `triangulation_min_ray_angle` | float | `1.0` | Min angle between views (degrees) | +| `triangulation_min_depth` | float | `0.001` | Min depth to accept a point | +| `triangulation_type` | str | `"FULL"` | `FULL` (all rays) or `ROBUST` (RANSAC) | +| `triangulation_refinement_iterations` | int | `10` | LM iterations for point refinement | +| `resection_threshold` | float | `0.004` | Resection outlier threshold (radians) | +| `resection_min_inliers` | int | `10` | Min resection inliers | + +## Tracks + +| Parameter | Type | Default | Description | +| ------------------------------- | ----- | ------- | ----------------------------------------- | +| `min_track_length` | int | `2` | Minimum images per track | +| `use_depth_prior` | bool | `false` | Use depth prior during BA | +| `depth_std_deviation_m_default` | float | `1.0` | Depth prior std deviation (m) | +| `depth_is_radial` | bool | `false` | Depth is distance to camera center (vs Z) | +| `depth_is_inverted` | bool | `false` | Depth is stored as inverse depth | + +## Bundle Adjustment + +| Parameter | Type | Default | Description | +| --------------------------------- | ----- | ------------- | ---------------------------------------------------- | +| `reprojection_error_sd` | float | `0.004` | Reprojection error std dev | +| `exif_focal_sd` | float | `0.01` | EXIF focal length std dev (log-scale) | +| `aspect_ratio_sd` | float | `0.01` | Aspect ratio (fu/fv) std dev (log-scale) | +| `principal_point_sd` | float | `0.01` | Principal point std dev | +| `radial_distortion_k1_sd` | float | `0.01` | k1 std dev | +| `radial_distortion_k2_sd` | float | `0.01` | k2 std dev | +| `radial_distortion_k3_sd` | float | `0.01` | k3 std dev | +| `radial_distortion_k4_sd` | float | `0.01` | k4 std dev | +| `tangential_distortion_p1_sd` | float | `0.01` | p1 std dev | +| `tangential_distortion_p2_sd` | float | `0.01` | p2 std dev | +| `gcp_horizontal_sd` | float | `0.01` | GCP horizontal std dev (m) | +| `gcp_vertical_sd` | float | `0.1` | GCP vertical std dev (m) | +| `gcp_global_weight` | float | `0.04` | GCP weight relative to observations | +| `gcp_observation_sd` | float | `0.001` | GCP reprojection observation std dev | +| `gcp_annealing_steps` | list | `[5.0, 25.0]` | GCP weight annealing schedule. `[1.0]` = single pass | +| `rig_translation_sd` | float | `0.1` | Rig translation std dev | +| `rig_rotation_sd` | float | `0.1` | Rig rotation std dev | +| `bundle_outlier_filtering_type` | str | `"FIXED"` | `FIXED` (threshold) or `AUTO` (distribution-based) | +| `bundle_outlier_auto_ratio` | float | `3.0` | AUTO: remove if error > ratio × mean | +| `bundle_outlier_fixed_threshold` | float | `0.006` | FIXED: max reprojection error | +| `optimize_camera_parameters` | bool | `true` | Optimize intrinsics during BA | +| `optimize_rig_parameters` | bool | `false` | Optimize rig parameters during BA | +| `bundle_max_iterations` | int | `100` | Max optimizer iterations | +| `bundle_outlier_weight_threshold` | float | `0.5` | Weight threshold for outlier removal | +| `bundle_irls_density_ratio` | float | `0.001` | IRLS outlier/inlier density ratio (all groups) | +| `bundle_irls_gcp_density_ratio` | float | `0.00001` | IRLS density ratio for GCP residuals | +| `bundle_irls_gps_density_ratio` | float | `0.00001` | IRLS density ratio for GPS residuals | + +### Stochastic Bundle + +For very large scenes, the global bundle switches to a stochastic solver that optimizes a random subset of cameras per round. + +| Parameter | Type | Default | Description | +| ----------------------------------- | ----- | ------- | ------------------------------------------------------------------------- | +| `stochastic_bundle_shot_count` | int | `4000` | Shot count above which the global bundle uses the stochastic solver | +| `stochastic_bundle_max_shots` | int | `500` | Max interior cameras optimized per round | +| `stochastic_bundle_random_shots` | int | `20` | Seed shots fed to each round | +| `stochastic_bundle_gcp_seeds_ratio` | float | `0.5` | Fraction of `stochastic_bundle_max_shots` reserved for GCP-anchored shots | +| `stochastic_bundle_radius` | int | `100` | Graph-hop radius for each round | +| `stochastic_bundle_max_rounds` | int | `100` | Upper bound on the number of rounds | + +## Incremental Reconstruction + +| Parameter | Type | Default | Description | +| -------------------------------- | ----- | -------- | ----------------------------------------------------- | +| `resect_redundancy_threshold` | float | `0.7` | Defer resection if (candidates / tracks) exceeds this | +| `retriangulation` | bool | `true` | Periodically re-triangulate all points | +| `retriangulation_ratio` | float | `1.2` | Re-triangulate when points grow by this ratio | +| `bundle_analytic_derivatives` | bool | `true` | Use analytic (vs auto-diff) derivatives | +| `bundle_interval` | int | `999999` | Bundle after adding this many cameras | +| `bundle_new_points_ratio` | float | `1.2` | Bundle when points grow by this ratio | +| `local_bundle_radius` | int | `3` | Image graph distance for local BA | +| `local_bundle_min_common_points` | int | `20` | Min common points to be a neighbor | +| `local_bundle_max_shots` | int | `30` | Max shots in local BA | +| `local_bundle_grid` | int | `12` | Grid divisions for track selection (local BA) | +| `final_bundle_grid` | int | `32` | Grid divisions for track selection (final BA) | +| `incremental_max_shots_count` | int | `0` | Limit max shots (0 = unlimited, for debugging) | +| `incremental_bootstrap_tries` | int | `10` | Initial image pairs tried for the reconstruction bootstrap | +| `incremental_bootstrap_min_inliers_ratio` | float | `0.8` | Min average resection inlier ratio to accept a bootstrap pair | +| `filter_final_point_cloud` | bool | `false` | Remove uncertain/isolated points | +| `save_partial_reconstructions` | bool | `false` | Save reconstruction at every iteration | + +## GPS / GCP Alignment + +| Parameter | Type | Default | Description | +| ---------------------------------- | ----- | -------------- | ---------------------------------------- | +| `use_altitude_tag` | bool | `true` | Use EXIF altitude tag | +| `align_method` | str | `"auto"` | `auto`, `orientation_prior`, or `naive` | +| `align_orientation_prior` | str | `"horizontal"` | `horizontal`, `vertical`, or `no_roll` | +| `bundle_use_gps` | bool | `true` | Enforce GPS in BA | +| `bundle_use_gcp` | bool | `true` | Enforce GCP in BA | +| `bundle_compensate_gps_bias` | bool | `false` | Per-camera GPS bias correction | +| `gcp_reprojection_error_threshold` | float | `0.05` | GCP reprojection error outlier threshold | + +## Rigs + +| Parameter | Type | Default | Description | +| ------------------------------ | ----- | ------- | ---------------------------------- | +| `rig_calibration_subset_size` | int | `15` | Rig instances used for calibration | +| `rig_calibration_completeness` | float | `0.85` | Required reconstructed-image ratio | +| `rig_calibration_max_rounds` | int | `10` | Max SfM rounds for rig calibration | + +## Undistortion + +| Parameter | Type | Default | Description | +| ---------------------------- | ---- | -------- | ----------------------------------- | +| `undistorted_image_format` | str | `"jpg"` | Output image format | +| `undistorted_image_max_size` | int | `100000` | Max dimension of undistorted images | + +## Depth Estimation (PatchMatch OpenCL) + +OpenSfM computes dense depthmaps with a GPU PatchMatch estimator (run by `compute_depthmaps`). These parameters control the per-view estimation and the consistency/visibility cleaning stage. + +| Parameter | Type | Default | Description | +| ---------------------------------- | ----- | -------- | ---------------------------------------------------------------- | +| `opencl_ignore_intel_gpu_device` | bool | `true` | Skip Intel GPUs for OpenCL (slow/buggy); set `false` to use them | +| `depthmap_num_neighbors` | int | `10` | Candidate neighboring views per image | +| `depthmap_num_matching_views` | int | `8` | Views actually used to estimate each depthmap | +| `depthmap_min_depth` | float | `0` | Min depth (m). `0` = auto-infer from the reconstruction | +| `depthmap_max_depth` | float | `0` | Max depth (m). `0` = auto-infer from the reconstruction | +| `depthmap_max_iterations` | int | `4` | Max PatchMatch iterations | +| `depthmap_patch_size` | int | `5` | Correlation patch window size (odd) | +| `depthmap_max_image_size` | int | `3200` | Max image dimension processed (longer side) | +| `depthmap_max_cost` | float | `0` | Max PatchMatch cost to keep a pixel (`0` = disabled) | +| `depthmap_same_depth_threshold` | float | `0.05` | Depth-closeness threshold (clean stage) | +| `depthmap_min_consistent_views` | int | `3` | Min consistent views to keep a pixel (clean stage) | +| `depthmap_carving_threshold` | float | `0.01` | Relative depth margin for a space-carving vote | +| `depthmap_max_carved_views` | int | `1` | Max carve votes before a pixel is discarded | +| `depthmap_carving_two_pass` | bool | `true` | Pass 1 = consistency only, pass 2 = carving | +| `depthmap_grazing_cos_threshold` | float | `0.2` | Cosine below which a pixel is "grazing" (stricter filtering) | +| `depthmap_sigma_spatial` | float | `1.5` | Bilateral NCC spatial sigma | +| `depthmap_sigma_color` | float | `≈0.098` | Bilateral NCC color sigma (normalized intensity, `25/255`) | +| `depthmap_use_census` | bool | `true` | Census-transform fallback in low-texture regions | +| `depthmap_hierarchy_levels` | int | `3` | Multi-scale levels (`1` = full-res only) | +| `depthmap_checkerboard_filter` | bool | `false` | Checkerboard bilateral median filter after PatchMatch | +| `depthmap_speckle_min_size` | int | `0` | Remove connected components smaller than this (px; `0` = off) | +| `depthmap_gap_max_size` | int | `0` | Max gap (px) for linear depth interpolation (`0` = disabled) | +| `depthmap_smooth_weight` | float | `0.2` | Depth/normal smoothness weight | +| `depthmap_anchor_views` | int | `2` | Views from the previous iteration kept in view selection | +| `depthmap_geom_consistency_weight` | float | `0.05` | Geometric-consistency cost weight (`0` = disabled) | +| `depthmap_cluster_max_size` | int | `16` | Max reference views per cluster | +| `depthmap_cluster_edge_max_factor` | float | `2.0` | Drop covisibility edges beyond this × median baseline (`0` = off) | +| `depthmap_max_cluster_views` | int | `48` | Hard cap on total views loaded per cluster batch | +| `depthmap_sfm_planar_prior` | bool | `false` | Seed a Delaunay planar prior from SfM points | +| `depthmap_neighbor_min_angle` | float | `3.0` | Min baseline angle (deg) for neighbor selection | +| `depthmap_neighbor_max_angle` | float | `60.0` | Max baseline angle (deg) for neighbor selection | +| `depthmap_save_debug_ply` | bool | `false` | Save per-shot/per-cluster debug PLYs (slow) | + +### Fusion + +Cleaned depthmaps are fused into a point cloud (and optional mesh) by a sparse-voxel-octree (SVO) TSDF fuser, run by `fuse_depthmaps`. The voxel size is auto-derived from the data's per-pixel ground footprint; these parameters tune extraction, decimation and optional photometric refinement. + +| Parameter | Type | Default | Description | +| --------------------------------------------- | ----- | ------------ | -------------------------------------------------------------------- | +| `depthmap_fusion_svo_voxel_level` | str | `"fine"` | Voxel resolution: `fine` / `half` / `quarter` (progressively coarser) | +| `depthmap_fusion_svo_trunc_factor` | float | `8` | TSDF truncation distance = factor × voxel size | +| `depthmap_fusion_svo_min_weight` | float | `2.0` | Min voxel weight to extract a surface point | +| `depthmap_fusion_svo_num_levels` | int | `3` | Multi-level fill passes (`1` = fine only) | +| `depthmap_fusion_svo_decimate_flat` | int | `2` | Flat-surface decimation (`1` = off; `N` keeps 1/N of flat points) | +| `depthmap_fusion_svo_edge_threshold` | float | `0.15` | Normal-divergence threshold protecting edges from decimation | +| `depthmap_fusion_svo_min_count` | int | `2` | Min observation count for both voxels of a zero-crossing | +| `depthmap_fusion_min_cell_observers` | int | `2` | Coverage-first view selection: min selected views observing a chunk cell before the per-chunk view budget is spent on quality | +| `depthmap_fusion_svo_relative_min_weight` | float | `0.25` | Local adaptive weight threshold for extraction | +| `depthmap_fusion_svo_max_voxels` | int | `80000000` | Max voxels per SVO sub-volume (clusters are split to fit) | +| `depthmap_fusion_svo_augment_neighbors` | int | `4` | Extra neighbor shots per cluster shot (boundary quality) | +| `depthmap_fusion_svo_coarse_factor` | int | `16` | Coarse pre-scan cell size = factor × voxel size | +| `depthmap_fusion_chunk_max_cells` | int | `0` | Fusion chunk size (max coarse cells per chunk); `0` = auto (one GPU sub-volume) | +| `depthmap_fusion_svo_refine_enabled` | bool | `false` | Photometric TSDF refinement | +| `depthmap_fusion_svo_refine_iters` | int | `50` | SDF refinement iterations | +| `depthmap_fusion_svo_refine_lambda_reg` | float | `0.3` | Laplacian regularization weight (`0` = disabled) | +| `depthmap_fusion_svo_refine_lambda_anchor` | float | `0.05` | Anchor weight pulling the refined TSDF toward the fused value | +| `depthmap_fusion_svo_refine_early_stop_rel` | float | `0.2` | Early-stop once RMS surface motion drops below this fraction of peak | +| `depthmap_fusion_svo_bake_reuse_max_fusers` | int | `4` | Max fusers kept resident on the GPU at once | +| `depthmap_fusion_svo_bake_reuse_vram_fraction`| float | `0.5` | VRAM fraction ceiling for retained fusers | +| `depthmap_fusion_mesh_enabled` | bool | `false` | Also extract a Surface Nets mesh (`mesh.ply`) from the fused TSDF (opt-in) | +| `depthmap_fusion_mesh_delete_batches` | bool | `true` | Delete per-cluster `mesh_batch_*.ply` after merging | + +### Dense disk reclamation and export + +| Parameter | Type | Default | Description | +| ---------------------------------- | ---- | ------- | --------------------------------------------------------------------- | +| `depthmap_delete_raw_after_clean` | bool | `true` | Delete each `.raw.npz` once its `.clean.npz` is produced (lossless) | +| `depthmap_delete_fusion_batches` | bool | `true` | Delete per-cluster `fused_batch_*.ply` after merging into `fused.ply` | +| `depthmap_delete_geotiff_batches` | bool | `false` | Delete per-cluster DSM/ortho tiles after the final merge | +| `dense_pointcloud_export_las` | bool | `true` | Also export the dense cloud as LAS (next to `fused.ply`) | +| `dense_pointcloud_export_laz` | bool | `true` | Also export the dense cloud as LAZ | + +## Octree Tiling + +Streaming tiles for the point-cloud/web viewer, built by `dense_merging` into the `point_cloud/` directory. + +| Parameter | Type | Default | Description | +| ---------------------------- | ---- | --------- | ---------------------------------------------------------------- | +| `octree_max_points_per_tile` | int | `50000` | Max points stored in a single octree tile | +| `octree_max_depth` | int | `15` | Max octree depth (root = 0) | +| `octree_lod_sample_count` | int | `10000` | LOD representative points kept in each inner (non-leaf) tile | +| `octree_split_depth` | int | `4` | Out-of-core build: depth at which points are bucketed to disk | +| `octree_max_bucket_points` | int | `8000000` | Out-of-core build: clouds larger than this are bucketed to disk | + +## Dense Colour Equalization + +`dense_equalize` estimates a per-image gain, white balance and radial vignetting from the SfM tracks (log-domain IRLS + PCG) so the dense colour bake (ortho, point cloud, mesh) is seam-free. Computed by the `dense_equalize` command; consumed during the bake when an `equalization.json` is present. + +| Parameter | Type | Default | Description | +| ---------------------------------- | ----- | ---------- | --------------------------------------------------------------------------- | +| `equalize_apply_in_bake` | bool | `true` | Apply the saved equalization during the dense colour bake (when `equalization.json` exists) | +| `equalize_highlight_knee` | float | `235.0` | Highlight protection: correction rolls off toward identity above this luminance (`255` = disable) | +| `equalize_vignette_order` | int | `2` | Number of radial vignetting coefficients (basis ρ², ρ⁴, …) | +| `equalize_min_track_length` | int | `3` | A track must be seen by at least this many reconstructed images to be used | +| `equalize_max_observations` | int | `4000000` | Cap on observations fed to the solver (whole tracks subsampled beyond it) | +| `equalize_saturation_margin` | float | `2.0` | Drop measurements within this many levels of 0/255 (clipped → unreliable) | +| `equalize_irls_iterations` | int | `5` | IRLS (Huber) reweighting iterations for robustness to specular/moving outliers | +| `equalize_huber_delta` | float | `2.0` | Huber transition, in robust-sigma units | +| `equalize_vignette_regularization` | float | `0.1` | Ridge on the vignetting coefficients (prior toward a flat lens) | +| `equalize_gain_regularization` | float | `0.001` | Tiny ridge on the per-image gains (conditioning only) | +| `equalize_gauge_weight` | float | `1.0` | Soft constraint `sum(log-gain)=0`, fixing the global scale and mean brightness | +| `equalize_pcg_tol` | float | `0.000001` | PCG (conjugate-gradient) solver tolerance per IRLS step | +| `equalize_pcg_max_iterations` | int | `2000` | PCG iteration cap per IRLS step | + +## DSM and Orthophoto + +The Digital Surface Model (`dsm.tif`) and orthophoto (`ortho.tif`) are rendered during fusion and finalized by `dense_merging`. The ground sample distance is derived automatically from the fused voxel size. + +| Parameter | Type | Default | Description | +| ------------------------------- | ----- | -------- | --------------------------------------------------------------------------------- | +| `dsm_enabled` | bool | `true` | Render the DSM + orthophoto during fusion | +| `dense_crop_to_sfm_hull` | bool | `true` | Crop dense outputs (cloud + DSM/ortho) to the PCA-trimmed convex hull of the SfM ground points | +| `dense_crop_percentile` | float | `1.0` | Outlier trim (percent) cut off each extremity along each PCA axis before hulling (`0` = plain hull) | +| `dsm_territory_depth_factor` | float | `2.0` | Drop depth samples farther than this × the view's median depth | +| `dsm_save_cluster_tiles` | bool | `true` | Also write each cluster's own DSM/ortho GeoTIFF before merging (debug) | +| `dsm_merge_feather` | bool | `true` | Merge per-cluster tiles by distance-transform feather blending | +| `dsm_feather_margin_cells` | int | `2` | Overlap (coarse cells) each chunk renders beyond its core for the feather/hole-fill band (`0` = hard seams) | +| `dsm_footprint_close_cells` | int | `128` | Morphological closing (coarse cells) of the occupied plan to define the completable footprint (bridges interior gaps) | +| `dsm_footprint_trim_fraction` | float | `0.1` | Trim sparse axis extremities below this fraction of the median occupied count before closing (`0` = off) | +| `dsm_merge_max_ram_mb` | int | `512` | Soft RAM budget (MB) for the band-by-band merge | +| `dsm_wall_cull_nz` | float | `0.5` | Rasterize a surface triangle only if \|cos\| of its normal from vertical ≥ this | +| `ortho_bake_n_final_views` | int | `3` | Sharpest inlier views blended for the final ortho color | +| `ortho_bake_irls_iterations` | int | `5` | Tukey-biweight reweighting iterations for the color consensus | +| `ortho_bake_dsm_occlusion` | bool | `true` | Drop views occluded by a taller surface when baking hole-filled cells | +| `ortho_detail_injection` | bool | `true` | Inject the high-frequency texture the multi-view blend low-passes away | +| `ortho_detail_sigma` | float | `2.0` | Gaussian sigma (ortho pixels) splitting the low/high bands for detail injection | +| `ortho_detail_strength` | float | `1.0` | Amount of high-pass added back (`1.0` = full; `>1` over-sharpens) | +| `ortho_median_threshold` | float | `24.0` | Gated 3×3 median despeckle: replace a pixel only if it differs by more than this | +| `hole_fill_diffuse_iters` | int | `1024` | Diffusion iterations used to fill DSM holes routed to edge-aware diffusion | +| `hole_fill_small_area_max` | int | `262144` | Area (cells) below which a hole is "small"; larger holes use the footprint logic | +| `hole_fill_low_flat_max_aspect` | float | `8.0` | Flat-fill a hole only if compact (bbox aspect ≤ this); elongated holes are diffused (`0` = off) | +| `hole_fill_low_flat_min_thickness` | float | `15.0` | Flat-fill a hole only if thick (max inscribed-disk radius ≥ this); thin holes are diffused (`0` = off) | +| `hole_fill_footprint_close` | int | `32` | Pixel-closing iterations for the per-tile enclosure footprint before `fill_holes` | +| `hole_fill_low_percentile` | float | `20.0` | Flat holes filled at this boundary-height percentile (gravity-aligned, low edge) | +| `hole_fill_occlusion_drop` | float | `0.0` | Fallback occluded-ground heuristic (superseded by `ortho_bake_dsm_occlusion`; `0` = off) | +| `dsm_shock_iterations` | int | `6` | Coherence-enhancing shock-filter iterations sharpening DSM edges | +| `dsm_shock_window` | int | `5` | Structure-tensor half-window (cells); larger = straighter edges | +| `dsm_shock_dt` | float | `0.25` | Shock-filter time step (keep ≤ 0.5 for stability) | +| `dsm_shock_coherence` | float | `0.1` | Along-edge diffusion weight (straightens jittered boundaries) | +| `dsm_shock_edge_slope` | float | `2.0` | Edge-strength gate: shock fires only where the local slope exceeds this | + +## Multi-Processing + +| Parameter | Type | Default | Description | +| -------------- | ---- | ------- | -------------------------------------------- | +| `processes` | int | `1` | Number of parallel processes | +| `io_processes` | int | `4` | Image-reading threads (when `processes > 1`) | + +## Submodel Split & Merge + +| Parameter | Type | Default | Description | +| ---------------------------------- | ----- | ---------------------------------- | ------------------------------------ | +| `submodel_size` | int | `80` | Average images per submodel | +| `submodel_overlap` | float | `30.0` | Overlap radius between submodels (m) | +| `submodels_relpath` | str | `"submodels"` | Submodels directory | +| `submodel_relpath_template` | str | `"submodels/submodel_%04d"` | Submodel directory template | +| `submodel_images_relpath_template` | str | `"submodels/submodel_%04d/images"` | Submodel images directory template | + +## Report Localization + +| Parameter | Type | Default | Description | +| -------------------- | ---- | ---------- | --------------------------------------------------- | +| `report_unit_system` | str | `"metric"` | Units in the quality report: `metric` or `imperial` | +| `report_language` | str | `"en"` | Report language: `en`, `fr`, `es`, `de`, `it` | diff --git a/doc/dataset.md b/doc/dataset.md new file mode 100644 index 000000000..7d400d33a --- /dev/null +++ b/doc/dataset.md @@ -0,0 +1,148 @@ +# Dataset Structure + +A dataset is a folder with the following layout. **Input** files are provided by the user; **output** files are created by pipeline commands. + +``` +project/ +│ +│── config.yaml # Input: pipeline configuration overrides +│── image_list.txt # Input (optional): explicit list of images to process +│── images/ # Input: source images +│ └── image_filename +│── masks/ # Input (optional): binary masks (0 = ignore region) +│ └── image_filename.png +│── segmentations/ # Input (optional): semantic segmentation maps +│ └── image_filename.png +│── instances/ # Input (optional): instance segmentation maps +│ └── image_filename.png +│── gcp_list.txt # Input (optional): ground control points (TXT format) +│── ground_control_points.json # Input (optional): ground control points (JSON format) +│── camera_models_overrides.json # Input (optional): override camera calibrations +│── exif_overrides.json # Input (optional): override EXIF metadata per image +│── rig_cameras.json # Input (optional): rig camera definitions +│── rig_assignments.json # Input (optional): image-to-rig-camera assignments +│ +│── exif/ # Output of extract_metadata +│── camera_models.json # Output of extract_metadata +│── reference_lla.json # Output of extract_metadata (topocentric origin) +│── features/ # Output of detect_features +│── matches/ # Output of match_features +│── tracks.csv # Output of create_tracks +│── reconstruction.json # Output of reconstruct +│── reconstruction.meshed.json # Output of mesh +│── reports/ # Output: per-command JSON reports +│ ├── features.json +│ ├── matches.json +│ ├── tracks.json +│ └── reconstruction.json +│── undistorted/ # Output of undistort +│ ├── images/ +│ ├── masks/ +│ ├── tracks.csv +│ ├── reconstruction.json +│ └── depthmaps/ # Output of the dense pipeline (clustering → depthmaps → fusion → merging) +│ ├── .clean.npz # cleaned per-shot depthmaps (compute_depthmaps) +│ ├── fused.ply # dense point cloud (dense_merging) +│ ├── mesh.ply # Surface Nets mesh +│ ├── fused.las, fused.laz # dense cloud as LAS/LAZ +│ ├── dsm.tif, ortho.tif # DSM and orthophoto (GeoTIFF) +│ └── point_cloud/ # Potree-style octree tiles for the viewer +│── stats/ # Output of compute_statistics / export_report +│ ├── stats.json +│ ├── report.pdf +│ ├── topview.png +│ ├── matchgraph.png +│ ├── heatmap_XXX.png +│ └── residuals_XXX.png +│── colmap_export/ # Output of export_colmap +└── profile.log # Profiling log (appended by various commands) +``` + +> **Note:** Previous versions of OpenSfM used a different folder structure where undistorted data was not grouped into a single folder. Use `bin/migrate_undistort.sh` to port old datasets to the new structure. + +See also: [configuration reference](configuration.md) for all `config.yaml` options. + +## Reconstruction File Format + +The main output of OpenSfM is `reconstruction.json`, containing estimated camera parameters, camera positions, and a sparse set of 3D points. The file is a JSON array of one or more reconstructions (disconnected components). + +``` +reconstruction.json: [RECONSTRUCTION, ...] + +RECONSTRUCTION: { + "cameras": { + CAMERA_ID: CAMERA, + ... + }, + "shots": { + SHOT_ID: SHOT, + ... + }, + "points": { + POINT_ID: POINT, + ... + }, + "rig_cameras": { # Present when rigs are used + RIG_CAMERA_ID: RIG_CAMERA, + ... + }, + "rig_instances": { # Present when rigs are used + RIG_INSTANCE_ID: RIG_INSTANCE, + ... + } +} +``` + +### Camera + +All camera models share `projection_type`, `width`, and `height`. Other parameters depend on the model. See [geometric models](geometry.md) for the projection equations. + +| `projection_type` | Parameters | +| ------------------------------- | ---------------------------------------------------------------- | +| `perspective` | `focal`, `k1`, `k2` | +| `simple_radial` | `focal_x`, `focal_y`, `c_x`, `c_y`, `k1` | +| `radial` | `focal_x`, `focal_y`, `c_x`, `c_y`, `k1`, `k2` | +| `brown` | `focal_x`, `focal_y`, `c_x`, `c_y`, `k1`, `k2`, `k3`, `p1`, `p2` | +| `fisheye` | `focal`, `k1`, `k2` | +| `fisheye_opencv` | `focal`, `c_x`, `c_y`, `k1`, `k2`, `k3`, `k4` | +| `fisheye62` | `focal`, `c_x`, `c_y`, `k1`–`k6`, `p1`, `p2` | +| `fisheye624` | `focal`, `c_x`, `c_y`, `k1`–`k6`, `p1`, `p2`, `s0`–`s3` | +| `spherical` / `equirectangular` | *(none beyond width/height)* | +| `dual` | `focal`, `k1`, `k2`, `transition` | + +Example (perspective): +```json +{ + "projection_type": "perspective", + "width": 4000, + "height": 3000, + "focal": 0.85, + "k1": -0.02, + "k2": 0.001 +} +``` + +### Shot + +``` +SHOT: { + "camera": CAMERA_ID, + "rotation": [X, Y, Z], # Rotation as angle-axis vector (world → camera) + "translation": [X, Y, Z], # Translation (world → camera) + "gps_position": [X, Y, Z], # GPS position in the reconstruction reference frame + "gps_dop": METERS, # GPS accuracy in meters + "orientation": NUMBER, # EXIF orientation tag (1, 3, 6, or 8) + "capture_time": SECONDS # Capture time as a UNIX timestamp +} +``` + +The camera origin in world coordinates is $-R^T t$. See [camera coordinates](geometry.md#camera-coordinates) for the full convention. + +### Point + +``` +POINT: { + "coordinates": [X, Y, Z], # Position in world coordinates + "color": [R, G, B] # Color (0–255) +} +``` diff --git a/doc/dense.md b/doc/dense.md new file mode 100644 index 000000000..aa06af493 --- /dev/null +++ b/doc/dense.md @@ -0,0 +1,138 @@ +# Dense Reconstruction & 2D Maps + +After a sparse reconstruction (camera poses + sparse points), OpenSfM can compute a **dense** representation of the scene and derive 2D map products from it: + +- a **dense point cloud** (`fused.ply`, optionally `fused.las` / `fused.laz`), +- a **triangle mesh** (`mesh.ply`, Surface Nets), +- a **Digital Surface Model** raster (`dsm.tif`) and an **orthophoto** (`ortho.tif`), +- streaming **octree tiles** for the web/point-cloud viewer (`point_cloud/`). + +![Dense Reconstruction](images/dense.png) +![DSM and Orthophoto](images/dsm_ortho.png) + +> The math behind depthmap backprojection, plane-induced homographies and undistortion is in [dense_matching.md](dense_matching.md). This page is the practical how-to. + +--- + +## Prerequisites + +1. A sparse reconstruction (`reconstruction.json`), i.e. the core pipeline up to `reconstruct`. +2. An **undistorted** reconstruction: run `undistort` first. The dense stages operate exclusively on `undistorted/` (perspective images, no radial distortion; spherical panoramas are split into several perspective views — see [dense_matching.md](dense_matching.md#undistortion)). +3. A **GPU with OpenCL**: depthmap estimation and fusion run on the GPU. + +All dense artefacts live under `undistorted/depthmaps/`. + +--- + +## The four-stage pipeline + +Dense reconstruction is split into four commands that hand off through disk, so each can run in its own process (bounded memory, clean teardown) — or even on a different machine sharing the dataset directory. Each later stage fails fast if an earlier stage's artefacts are missing. + +| # | Command | Reads | Writes | +| - | ------- | ----- | ------ | +| 1 | `dense_clustering` | undistorted reconstruction + tracks | clusters, super-points, neighbours, depth ranges, cluster bounding boxes | +| 2 | `compute_depthmaps` | stage-1 artefacts + undistorted images | per-shot `*.raw.npz` → `*.clean.npz` depthmaps | +| 3 | `fuse_depthmaps` | cleaned depthmaps + stage-1 artefacts | per-cluster `fused_batch_*.ply` (+ `mesh_batch_*.ply`, DSM/ortho tiles) | +| 4 | `dense_merging` | per-cluster batches | `fused.ply`, `mesh.ply`, `dsm.tif`, `ortho.tif`, `fused.las/.laz`, `point_cloud/` | + +```bash +bin/opensfm undistort path/to/dataset +bin/opensfm dense_clustering path/to/dataset +bin/opensfm compute_depthmaps path/to/dataset +bin/opensfm fuse_depthmaps path/to/dataset +bin/opensfm dense_merging path/to/dataset # add --georeferenced for geo-located LAS/LAZ + DSM/ortho +``` + +### Stage 1 — `dense_clustering` + +Groups covisible shots into small clusters and prepares the per-shot context the GPU stages need: + +- **Clusters** — shots that see common surface are grouped (bounded by `depthmap_cluster_max_size`) so fusion can process the scene in disjoint, memory-bounded chunks. +- **Super-points** — sparse track points fused per cluster, used to seed depth ranges and priors. +- **Neighbours** — for each shot, the best matching views for stereo (gated by baseline angle, `depthmap_neighbor_min_angle` / `_max_angle`) are stored as `neighbors_best.json` / `neighbors_all.json`. +- **Depth ranges** — a per-shot `[min, max]` depth used to bound the PatchMatch search. + +Outputs: `clusters.json`, `clusters_points.json`, `neighbors_best.json`, `neighbors_all.json`, `depth_ranges.json`, `cluster_bboxes.json`. + +### Stage 2 — `compute_depthmaps` + +Estimates a depth + normal map for every shot, then cleans it. + +- **Estimation** — a PatchMatch-based multi-view stereo (ACMMP-style: adaptive checkerboard propagation with multi-hypothesis sampling) running on the GPU via OpenCL. It is **multi-scale** (`depthmap_hierarchy_levels`), uses a bilateral-NCC matching cost with a Census-transform fallback in low-texture regions (`depthmap_use_census`), and can add a geometric-consistency term across neighbouring views (`depthmap_geom_consistency_weight`). +- **Cleaning** — a consistency + visibility pass keeps only depths confirmed by enough neighbours (`depthmap_min_consistent_views`) and carves away depths that are occluded by other views (`depthmap_carving_threshold`, two-pass by default). + +Outputs (per shot, lz4-compressed NumPy): `.raw.npz` then `.clean.npz`. The raw map is deleted once its clean counterpart exists (`depthmap_delete_raw_after_clean`). Set `depthmap_save_debug_ply` to also dump per-shot/per-cluster PLYs for inspection. + +See the [Depth Estimation](configuration.md#depth-estimation-patchmatch-opencl) config for all knobs. + +### Stage 3 — `fuse_depthmaps` + +Fuses the cleaned depthmaps into a surface using a **sparse-voxel-octree (SVO) TSDF** integrator, one cluster at a time over disjoint territories (so two clusters never fuse the same surface twice). + +- **Voxel size is automatic**, derived from the data's median per-pixel ground footprint (`depth/focal`); `depthmap_fusion_svo_voxel_level` selects `fine` / `half` / `quarter` of that resolution. +- **Mesh** — when `depthmap_fusion_mesh_enabled` is set (opt-in, **off by default**), a **Surface Nets** (dual-contouring) triangle mesh is extracted from the same TSDF zero-set. Unlike Poisson it never balloons facades, but it leaves holes where the volume is empty. +- **2D tiles** — when `dsm_enabled` (default), each cluster also renders its slice of the DSM and orthophoto. + +Outputs per cluster: `fused_batch_*.ply` (xyz + normal + rgb), `mesh_batch_*.ply`, `dsm_ortho_batch_*.npz`. See the [Fusion](configuration.md#fusion) config. + +### Stage 4 — `dense_merging` + +Merges the per-cluster batches into the final products and exports them: + +- **`fused.ply`** — the merged dense point cloud (always topocentric). +- **`mesh.ply`** — the merged Surface Nets mesh, when `depthmap_fusion_mesh_enabled` is set (always topocentric). +- **`dsm.tif` / `ortho.tif`** — the merged DSM and orthophoto GeoTIFFs (per-cluster tiles are feather-blended, `dsm_merge_feather`). +- **`fused.las` / `fused.laz`** — the dense cloud as LAS/LAZ, when `dense_pointcloud_export_las` / `_laz` are enabled. +- **`point_cloud/`** — Potree-style octree tiles for streaming in a web/point-cloud viewer (see [Octree Tiling](configuration.md#octree-tiling)). + +Add **`--georeferenced`** to write the LAS/LAZ and DSM/ortho in the output coordinate system instead of the topocentric frame. The point cloud PLY, mesh and octree always stay topocentric. See [Georeferencing & GIS outputs](georeferencing.md) for how the output CRS is chosen and a full GIS walkthrough. + +--- + +## Output reference + +All paths are relative to `undistorted/depthmaps/`. + +| File | Format | Produced by | Georeferenced? | +| ---- | ------ | ----------- | -------------- | +| `.clean.npz` | lz4 NPZ (depth, normal, score) | `compute_depthmaps` | — | +| `fused.ply` | binary PLY (xyz + normal + rgb) | `dense_merging` | no (topocentric) | +| `mesh.ply` | binary PLY mesh | `dense_merging` | no (topocentric) | +| `fused.las`, `fused.laz` | LAS / LAZ 1.4 | `dense_merging` | with `--georeferenced` | +| `dsm.tif` | single-band Float32 GeoTIFF | `dense_merging` | with `--georeferenced` (else topocentric grid) | +| `ortho.tif` | RGB + alpha GeoTIFF | `dense_merging` | with `--georeferenced` (else topocentric grid) | +| `point_cloud/` | Potree octree tiles | `dense_merging` | no (topocentric) | + +> **Legacy note:** older docs/tools refer to `merged.ply`; the current pipeline writes `fused.ply`. `export_geocoords --dense` still reads the legacy `merged.ply` — for georeferenced dense products use `dense_merging --georeferenced`. + +--- + +## 2D maps: DSM and orthophoto + +The **DSM** (`dsm.tif`) is a top-down height raster; the **orthophoto** (`ortho.tif`) is a top-down, perspective-corrected color image on the same grid. Both are rendered from the fused surface during stage 3 and finalized in stage 4. The ground sample distance is derived automatically from the fused voxel size. + +DSM/ortho quality is shaped by a dedicated config group ([DSM and Orthophoto](configuration.md#dsm-and-orthophoto)): shape-aware hole filling (compact gaps flat-filled, elongated ones edge-aware diffused), a coherence-enhancing shock filter that sharpens edges, robust multi-view color baking with detail injection for the ortho, and feather blending across cluster tiles. Set `dsm_enabled: false` to skip 2D maps entirely. + +--- + +## Performance & disk usage + +- **Run stages separately.** The four-command split keeps peak memory bounded; you can stop and resume between stages, and re-run a single stage after changing its config. +- **Disk reclamation.** Intermediate artefacts are removed by default once consumed (`depthmap_delete_raw_after_clean`, `depthmap_delete_fusion_batches`, `depthmap_fusion_mesh_delete_batches`). Set the relevant flag to `false` to keep them for debugging. See [Dense disk reclamation and export](configuration.md#dense-disk-reclamation-and-export). +- **Image size.** `depthmap_max_image_size` caps the processed resolution; `undistorted_image_max_size` caps the undistorted images. + +--- + +## Visualizing the results + +- Point cloud / mesh: open `fused.ply` or `mesh.ply` in [MeshLab](http://www.meshlab.net/) or any PLY viewer. +- DSM / ortho: open the GeoTIFFs in QGIS or any GIS tool. +- Web viewer: serve the `point_cloud/` octree (Potree-style tiles). +- Rerun: `export_rerun` for an interactive 3D session. + +## See also + +- [Pipeline commands](using.md#dense-reconstruction-dense_clustering-compute_depthmaps-fuse_depthmaps-dense_merging) — command reference. +- [Configuration reference](configuration.md) — Depth Estimation, Fusion, DSM and Orthophoto, Octree Tiling. +- [Georeferencing & GIS outputs](georeferencing.md) — output CRS, GCPs, and a large-scale aerial walkthrough. +- [Dense matching notes](dense_matching.md) — the underlying math. diff --git a/doc/dense_matching.md b/doc/dense_matching.md new file mode 100644 index 000000000..eb5d50422 --- /dev/null +++ b/doc/dense_matching.md @@ -0,0 +1,81 @@ +# Dense Matching Notes + +Mathematical notes on the dense matching module. + +## Backprojection at a Given Depth + +The backprojection of a pixel $q = (q_x, q_y, 1)^T$ at depth $d$ in camera coordinates is: + +$$X = d K^{-1} q$$ + +## Backprojection to a Plane + +The backprojection of a pixel $q = (q_x, q_y, 1)^T$ onto the plane $\pi = (v^T, 1)$ is: + +$$X = \frac{-K^{-1} q}{v^T K^{-1} q}$$ + +with depth: + +$$d = \frac{-1}{v^T K^{-1} q}$$ + +## Plane Given Point and Normal + +The plane + +$$\pi = \left( \frac{-n^T}{n^T X},\ 1 \right)$$ + +contains point $X$ and has normal $n$. + +## Plane of Constant Depth + +A plane of constant depth $d$ is defined by $z = d$ in camera coordinates: + +$$\pi_c = (0, 0, -1/d, 1)$$ + +## Plane Coordinates Conversion + +A plane's coordinates in world and camera frames are related by: + +$$\pi_w = \begin{pmatrix} R & t \\ 0 & 1 \end{pmatrix} \pi_c$$ + +## Plane-Induced Homography + +Given a plane in camera coordinates $\pi_c = (v^T, 1)$, the homography from image 1 to image 2 is: + +$$H = K_2 \left[R_2 R_1^T + (R_2 R_1^T t_1 - t_2)\, v^T\right] K_1^{-1}$$ + +Pre-computing: + +$$Q_{12} = R_2 R_1^T, \quad a_{12} = R_2 R_1^T t_1 - t_2$$ + +gives: + +$$H = K_2 \left[Q_{12} + a_{12}\, v^T\right] K_1^{-1}$$ + +## Local Affine Approximation of a Homography + +The homography mapping defined by matrix $H$ is: + +$$f(x, y) = \begin{pmatrix} u/w \\ v/w \end{pmatrix}$$ + +where $u = H_1 (x, y, 1)^T$, $v = H_2 (x, y, 1)^T$, $w = H_3 (x, y, 1)^T$. + +The differential is: + +$$Df(x, y) = \frac{1}{w^2} +\begin{pmatrix} + H_{11} w - H_{31} u & H_{12} w - H_{32} u \\ + H_{21} w - H_{31} v & H_{22} w - H_{32} v +\end{pmatrix}$$ + +The linear approximation around $(x_0, y_0)$ is: + +$$f(x_0 + dx,\ y_0 + dy) = f(x_0, y_0) + Df(x_0, y_0)(dx, dy)^T$$ + +## Undistortion + +The dense module assumes perspective images with no radial distortion. For perspective images, undistorted versions are generated using estimated distortion parameters $k_1$, $k_2$. + +Spherical (360°) images cannot be unwarped into a single perspective view. Multiple perspective views are generated to cover the full field of view. + +Undistortion is therefore a process that takes a reconstruction as input and produces a new reconstruction as output. The input may contain radially distorted images and panoramas; the output contains only undistorted perspective images. A new track graph is also generated. diff --git a/doc/geometry.md b/doc/geometry.md new file mode 100644 index 000000000..171578b9c --- /dev/null +++ b/doc/geometry.md @@ -0,0 +1,217 @@ +# Geometric Models + +## Coordinate Systems + +### Normalized Image Coordinates + +The 2D position of a point in images is stored in *normalized image coordinates*. The origin is in the middle of the image. The x coordinate grows to the right and y grows downward. The larger dimension of the image is 1. + +For example, all pixels in a 4:3 image are in the intervals `[-0.5, 0.5]` (x) and `[-0.375, 0.375]` (y). + +``` + +-----------------------------+ + | | + | | + | | + | + ------------>| + | | (0,0) (0.5,0) + | | | + | v | + +-----------------------------+ + (0, 0.5) +``` + +Normalized coordinates are resolution-independent and give better numerical stability for multi-view geometry algorithms. + +### Pixel Coordinates + +Many OpenCV functions use *pixel coordinates* where the origin is at the center of the top-left pixel, x grows right, y grows downward. The bottom-right pixel is at `(width - 1, height - 1)`. + +The transformation from normalized to pixel coordinates is: + +$$ +H = \begin{pmatrix} + \max(w, h) & 0 & \frac{w-1}{2} \\ + 0 & \max(w, h) & \frac{h-1}{2} \\ + 0 & 0 & 1 + \end{pmatrix} +$$ + +and its inverse: + +$$ +H^{-1} = \begin{pmatrix} + 1 & 0 & -\frac{w-1}{2} \\ + 0 & 1 & -\frac{h-1}{2} \\ + 0 & 0 & \max(w, h) + \end{pmatrix} +$$ + +where $w$ and $h$ are the image width and height. + +### Upright Coordinates + +When taking pictures, a camera may be in portrait or landscape orientation. Most cameras store this in the EXIF `orientation` tag, and OpenSfM applies the appropriate rotation to masks automatically. Note that normalized and pixel coordinates are *not* corrected for upright — they reflect the original image pixel order. + +### World Coordinates + +Reconstructed 3D points are stored in *world coordinates*, an arbitrary Euclidean reference frame. + +When GPS data is available, a topocentric reference frame is used: origin near the ground, X pointing east, Y pointing north, Z pointing up. The origin's latitude, longitude and altitude are stored in `reference_lla.json`. + +When GPS is not available, OpenSfM tries to orient the frame so Z is vertical and the ground is near $z = 0$. + +### Camera Coordinates + +The *camera coordinate* frame has the origin at the optical center, X pointing right, Y pointing down, Z pointing forward. A point in front of the camera has positive Z. + +In the 3D reconstruction viewer, axes are Red (x), Green (y), Blue (z). + +![Camera coordinate system](images/id-rotation.png) + +The camera pose is the rotation and translation converting world coordinates to camera coordinates. + +#### Pose Representation + +The `Pose` class stores rotation as an **axis-angle vector** (3D): + +- The **direction** is the rotation axis. +- The **length** is the rotation angle in radians. + +The camera origin in world coordinates is $-R^T t$. Use `shot.pose.get_origin()` / `set_origin()` to read or write camera position directly. + +See `opensfm/types.py` (`Reconstruction`, `Shot`) and `opensfm/src/geometry/`. + + +## Camera Models + +Camera models project 3D points in *camera coordinates* $(x, y, z)$ to *normalized image coordinates* $(u, v)$. + +### Perspective (`perspective`) + +$$ +\begin{array}{l} +x_n = \frac{x}{z} \\ +y_n = \frac{y}{z} \\ +r^2 = x_n^2 + y_n^2 \\ +d = 1 + k_1 r^2 + k_2 r^4 \\ +u = f\, d\, x_n \\ +v = f\, d\, y_n +\end{array} +$$ + +### Simple Radial (`simple_radial`) + +$$ +\begin{array}{l} +x_n = \frac{x}{z},\quad y_n = \frac{y}{z} \\ +r^2 = x_n^2 + y_n^2 \\ +d = 1 + k_1 r^2 \\ +u = f_x\, d\, x_n + c_x \\ +v = f_y\, d\, y_n + c_y +\end{array} +$$ + +### Radial (`radial`) + +$$ +\begin{array}{l} +x_n = \frac{x}{z},\quad y_n = \frac{y}{z} \\ +r^2 = x_n^2 + y_n^2 \\ +d = 1 + k_1 r^2 + k_2 r^4 \\ +u = f_x\, d\, x_n + c_x \\ +v = f_y\, d\, y_n + c_y +\end{array} +$$ + +### Brown (`brown`) + +$$ +\begin{array}{l} +x_n = \frac{x}{z},\quad y_n = \frac{y}{z} \\ +r^2 = x_n^2 + y_n^2 \\ +d_r = 1 + k_1 r^2 + k_2 r^4 + k_3 r^6 \\ +d^t_x = 2p_1\, x_n\, y_n + p_2\,(r^2 + 2x_n^2) \\ +d^t_y = 2p_2\, x_n\, y_n + p_1\,(r^2 + 2y_n^2) \\ +u = f_x\,(d_r\, x_n + d^t_x) + c_x \\ +v = f_y\,(d_r\, y_n + d^t_y) + c_y +\end{array} +$$ + +### Fisheye (`fisheye`) + +$$ +\begin{array}{l} +r^2 = x^2 + y^2 \\ +\theta = \arctan(r / z) \\ +d = 1 + k_1\theta^2 + k_2\theta^4 \\ +u = f\, d\, \theta\, \frac{x}{r} \\ +v = f\, d\, \theta\, \frac{y}{r} +\end{array} +$$ + +### Fisheye OpenCV (`fisheye_opencv`) + +$$ +\begin{array}{l} +r = \sqrt{x^2 + y^2} \\ +\theta = \arctan(r / z) \\ +d_r = 1 + k_1\theta^2 + k_2\theta^4 + k_3\theta^6 + k_4\theta^8 \\ +u = f\,(d_r\, \theta\, \frac{x}{r}) + c_x \\ +v = f\,(d_r\, \theta\, \frac{y}{r}) + c_y +\end{array} +$$ + +### Fisheye 62 (`fisheye62`) + +$$ +\begin{array}{l} +r = \sqrt{x^2 + y^2} \\ +\theta = \arctan(r / z) \\ +d_r = 1 + k_1\theta^2 + k_2\theta^4 + k_3\theta^6 + k_4\theta^8 + k_5\theta^{10} + k_6\theta^{12} \\ +d^t_x = 2p_1\, x_n\, y_n + p_2\,(\theta^2 + 2x_n^2) \\ +d^t_y = 2p_2\, x_n\, y_n + p_1\,(\theta^2 + 2y_n^2) \\ +u = f\,(d_r\, \theta\, \frac{x}{r} + d^t_x) + c_x \\ +v = f\,(d_r\, \theta\, \frac{y}{r} + d^t_y) + c_y +\end{array} +$$ + +### Fisheye 624 (`fisheye624`) + +$$ +\begin{array}{l} +r = \sqrt{x^2 + y^2} \\ +\theta = \arctan(r / z) \\ +d_r = 1 + k_1\theta^2 + k_2\theta^4 + k_3\theta^6 + k_4\theta^8 + k_5\theta^{10} + k_6\theta^{12} \\ +d^t_x = 2p_1\, x_n\, y_n + p_2\,(\theta^2 + 2x_n^2) \\ +d^t_y = 2p_2\, x_n\, y_n + p_1\,(\theta^2 + 2y_n^2) \\ +d^s_x = s_0\, \theta^2 + s_1\, \theta^4 \\ +d^s_y = s_2\, \theta^2 + s_3\, \theta^4 \\ +u = f\,(d_r\, \theta\, \frac{x}{r} + d^t_x + d^s_x) + c_x \\ +v = f\,(d_r\, \theta\, \frac{y}{r} + d^t_y + d^s_y) + c_y +\end{array} +$$ + +### Spherical / Equirectangular (`spherical` or `equirectangular`) + +$$ +\begin{array}{l} +\mathrm{lon} = \arctan\!\left(\frac{x}{z}\right) \\ +\mathrm{lat} = \arctan\!\left(\frac{-y}{\sqrt{x^2 + z^2}}\right) \\ +u = \frac{\mathrm{lon}}{2\pi} \\ +v = -\frac{\mathrm{lat}}{2\pi} +\end{array} +$$ + +### Dual (`dual`) + +$$ +\begin{array}{l} +r^2 = x^2 + y^2 \\ +x^n_p = \frac{x}{z},\quad y^n_p = \frac{y}{z} \\ +x^n_f = \theta\, \frac{x}{r},\quad y^n_f = \theta\, \frac{y}{r} \\ +d = 1 + k_1\theta^2 + k_2\theta^4 \\ +u = f\, d\,(l\, x^n_p + (1-l)\, x^n_f) \\ +v = f\, d\,(l\, y^n_p + (1-l)\, y^n_f) +\end{array} +$$ diff --git a/doc/georeferencing.md b/doc/georeferencing.md new file mode 100644 index 000000000..5de18121c --- /dev/null +++ b/doc/georeferencing.md @@ -0,0 +1,177 @@ +# Georeferencing & GIS Outputs + +OpenSfM reconstructs in a **local topocentric frame** but can ingest geospatial inputs (GPS, external geolocation, ground control points in any CRS) and emit **georeferenced** products (LAS/LAZ, DSM and orthophoto GeoTIFFs). This page explains how coordinates flow through the pipeline and walks through a full large-scale aerial example. + +If you only need a quick dense cloud, see [Dense reconstruction & 2D maps](dense.md). This page is for the GIS / mapping user. + +--- + +## The internal frame: topocentric ENU + +Reconstructions live in a local **East-North-Up (ENU)** Euclidean frame whose origin is stored in `reference_lla.json` (`{latitude, longitude, altitude}`): + +- The origin is chosen automatically from the input GPS (a precision-weighted average of the image positions), or from the GCPs if there is no GPS. The origin **altitude is forced to 0**, so heights are relative to that reference, not to the ellipsoid. +- All shot poses and points in `reconstruction.json` are expressed in this ENU frame. The `TopocentricConverter` (see `opensfm/geo.py`) converts ENU ↔ latitude/longitude/altitude ↔ ECEF. + +See [geometry.md](geometry.md) for the full coordinate-system reference. + +--- + +## The output coordinate system + +Georeferenced products (LAS/LAZ, DSM/ortho, and the report's coordinate row) are written in an **output CRS that OpenSfM derives automatically**: + +1. **If your GCPs are given in a _projected_ CRS, that CRS is used verbatim.** +2. **Otherwise, OpenSfM falls back to the UTM zone** containing the reconstruction's reference point (`EPSG:326xx` in the northern hemisphere, `EPSG:327xx` in the southern). + +A geographic GCP CRS (plain `WGS84` / `EPSG:4326`, i.e. lat-lon degrees) counts as **not projected** and therefore triggers the UTM fallback — you can't make a metric raster directly in degrees. + +> **To control the output CRS, give your GCPs in the projected CRS you want** (even a handful is enough to set it). With no projected GCP CRS, you get the local UTM zone, which is the right default for most aerial datasets. + +This single rule (implemented in `DataSet.output_coordinate_system()` → `geo.decide_output_crs`) is used by all georeferenced exports, so they are always mutually consistent. + +CRS strings are parsed by PROJ and accept `WGS84`, `EPSG:xxxx`, compound EPSG (e.g. `EPSG:4979+5773` for a vertical datum), or a raw `+proj=...` string. Missing datum/geoid grids are fetched from the PROJ CDN when `proj_cdn_enabled` is set (with an optional local cache, `proj_grid_cache_dir`). + +--- + +## Supplying inputs in a custom CRS + +### External geolocation from a text file + +When GPS is not in the image EXIF (or you have a better RTK/PPK solution), import it from a CSV/TXT file with [`extract_geolocation`](using.md#extract_geolocation): + +```bash +bin/opensfm extract_geolocation --geotag-file geotags.txt --crs EPSG:2056 path/to/dataset +bin/opensfm extract_metadata path/to/dataset # rerun so the values reach the reconstruction +``` + +`--crs` is the CRS of the position columns (e.g. `EPSG:2056` Swiss LV95, or `WGS84`). The file can also carry per-image position standard deviations and yaw/pitch/roll (stored as an OPK orientation prior). The imported values land in `exif_overrides.json` and **override any EXIF GPS**. See the [command reference](using.md#extract_geolocation) for the exact format. + +### Ground control points in a custom CRS + +GCPs are the most accurate way to georeference, and they also set the output CRS (rule 1 above). Both file formats carry their own CRS: + +- `gcp_list.txt` — the first line is the CRS (e.g. `WGS84`, `WGS84 UTM 32N`, `EPSG:2056`, or any proj4 string); each subsequent line is `x y z pixel_x pixel_y image_name`. +- `ground_control_points.json` — a top-level `"crs"` plus points with `position` and normalized-image `observations`. + +See [ground_control_points.md](ground_control_points.md) for the full schemas and the two-step way GCPs enter the pipeline (global alignment, then bundle adjustment). Use [`convert_gcp`](using.md#convert_gcp) to convert between the two formats. + +--- + +## Georeferenced outputs + +### Dense products — `dense_merging --georeferenced` + +The recommended path for georeferenced **dense** products: + +```bash +bin/opensfm dense_merging --georeferenced path/to/dataset +``` + +This writes the LAS/LAZ point cloud and the DSM/ortho GeoTIFFs in the **derived output CRS** (with the CRS embedded in the files). The DSM/ortho are reprojected from the topocentric grid to a north-up grid via GDAL. The `fused.ply` cloud, the `mesh.ply` mesh, and the `point_cloud/` octree always remain topocentric. See [Dense reconstruction & 2D maps](dense.md). + +### Sparse products — `export_geocoords --proj` + +To reproject the **sparse** reconstruction and/or image positions into an explicit CRS you pass directly: + +```bash +bin/opensfm export_geocoords --proj '+proj=utm +zone=32 +north +datum=WGS84' \ + --reconstruction --image-positions path/to/dataset +``` + +Unlike `dense_merging`, `export_geocoords` takes the CRS from its `--proj` argument rather than deriving it. It writes `reconstruction.geocoords.json`, `image_geocoords.tsv`, and/or the transformation matrix. See the [command reference](using.md#export_geocoords). + +### Quality report + +`compute_statistics` + `export_report` embed the output CRS and (when present) DSM/ortho thumbnails in `stats/report.pdf`. See [quality_report.md](quality_report.md). + +--- + +## Pre-calibrated cameras and rigs + +Aerial and survey workflows often use lab-calibrated cameras and fixed multi-camera rigs. OpenSfM lets you inject and **freeze** that calibration. + +### Lab-calibrated intrinsics + +Put the known intrinsics in `camera_models_overrides.json` (same schema as `camera_models.json`; the special key `"all"` applies one calibration to every camera). To **hold them fixed** during reconstruction instead of refining them, set in `config.yaml`: + +```yaml +optimize_camera_parameters: false +``` + +With this, the camera parameter blocks are added to bundle adjustment as constant. See [extract_metadata → Providing Your Own Camera Parameters](using.md#providing-your-own-camera-parameters). + +### Rigs with fixed relative poses + +Define the rig with `rig_cameras.json` (each camera's pose in the rig frame) and `rig_assignments.json` (which shots belong to which rig instance), or generate them with [`create_rig`](rig.md). The relative rig-camera poses are **kept constant by default** (`optimize_rig_parameters: false`); each rig instance's world pose is still optimized against GPS and image observations. See [rig.md](rig.md) for the model and the pattern/metadata calibration methods. + +--- + +## End-to-end: large aerial dataset with GCPs and a custom CRS + +A worked example combining everything: a multi-camera rig, lab calibration, RTK geotags in a projected CRS, GCPs, and georeferenced outputs in EPSG:2056. + +**1. Lay out the dataset and config.** In `config.yaml`: + +```yaml +optimize_camera_parameters: false # trust the lab calibration +five_point_reversal_check: true # robustness for near-nadir aerial geometry +processes: 8 +``` + +Place files at the dataset root: +- `images/` — the imagery. +- `camera_models_overrides.json` — lab calibration (use `"all"` or per-camera). +- `rig_cameras.json` + `rig_assignments.json` — the rig model (or create them in step 3). +- `geotags.txt` — per-image RTK positions in EPSG:2056. +- `ground_control_points.json` (or `gcp_list.txt`) — GCPs **with CRS `EPSG:2056`** (this sets the output CRS). + +**2. Import geolocation and metadata.** + +```bash +bin/opensfm extract_geolocation --geotag-file geotags.txt --crs EPSG:2056 DATA +bin/opensfm extract_metadata DATA +``` + +**3. Features, matching, rig, tracks.** + +```bash +bin/opensfm detect_features DATA +bin/opensfm match_features DATA +bin/opensfm create_rig DATA --method=pattern --calibration-type=metadata --definition='{...}' # if not pre-written +bin/opensfm create_tracks DATA +``` + +**4. Reconstruct (GPS + GCP constrained) and densify.** + +```bash +bin/opensfm reconstruct DATA +bin/opensfm undistort DATA +bin/opensfm dense_clustering DATA +bin/opensfm compute_depthmaps DATA +bin/opensfm fuse_depthmaps DATA +bin/opensfm dense_merging --georeferenced DATA +``` + +**5. Georeferenced results** (all in EPSG:2056, from the GCP CRS): +- `undistorted/depthmaps/fused.las` / `fused.laz` — dense cloud. +- `undistorted/depthmaps/dsm.tif` / `ortho.tif` — DSM and orthophoto. + +**6. Reports and sparse export (optional).** + +```bash +bin/opensfm compute_statistics DATA +bin/opensfm export_report DATA +bin/opensfm export_geocoords --proj 'EPSG:2056' --reconstruction --image-positions DATA +``` + +--- + +## See also + +- [Dense reconstruction & 2D maps](dense.md) +- [Ground control points](ground_control_points.md) +- [Rig models](rig.md) +- [Camera models & coordinate systems](geometry.md) +- [Pipeline commands](using.md) — `extract_geolocation`, `convert_gcp`, `export_geocoords`, `dense_merging` +- [Configuration reference](configuration.md) diff --git a/doc/ground_control_points.md b/doc/ground_control_points.md new file mode 100644 index 000000000..9805e74db --- /dev/null +++ b/doc/ground_control_points.md @@ -0,0 +1,90 @@ +# Ground Control Points + +Ground control points (GCPs) are landmarks with known geospatial positions, marked in one or more images. They are the most accurate way to georeference a reconstruction and to set its [output coordinate system](georeferencing.md#the-output-coordinate-system). GCPs complement — or replace — the GPS positions read from image EXIF. + +OpenSfM uses GCPs in two steps, **both of which require a point to be observed in at least two reconstructed images** (the point's position is triangulated from its image rays; a point seen in fewer than two reconstructed shots is ignored): + +- **Alignment**: globally rotates, translates and scales the reconstruction so the triangulated GCPs match their survey positions. +- **Bundle adjustment**: adds each GCP as a position prior plus per-observation reprojection constraints, refining the reconstruction. + +Each point also carries a **role**: + +- `gcp` (default) — used in both alignment and bundle adjustment. +- `checkpoint` — *excluded* from optimization and used only to measure accuracy; checkpoint residuals are reported separately in the [quality report](quality_report.md#gpsgcp-errors-details). + +Key config parameters: `bundle_use_gcp`, `gcp_horizontal_sd`, `gcp_vertical_sd`, `gcp_global_weight`, `gcp_annealing_steps`, `gcp_reprojection_error_threshold`. See the [configuration reference](configuration.md#bundle-adjustment). Per-point standard deviations (see below) override `gcp_horizontal_sd` / `gcp_vertical_sd` for that point. + +GCPs can be supplied in two interchangeable formats — `ground_control_points.json` or `gcp_list.txt` — placed at the dataset root. Use [`convert_gcp`](using.md#convert_gcp) to convert between them (it backs up the target file as `.bak` first). + +## JSON Format + +Add a file named `ground_control_points.json` at the dataset root: + +```json +{ + "crs": "WGS84", + "points": [ + { + "id": "gcp_id", + "role": "gcp", + "position": { + "latitude": 52.519, + "longitude": 13.400, + "altitude": 14.946, + "latitude_std": 0.02, + "longitude_std": 0.02, + "altitude_std": 0.05 + }, + "observations": [ + { + "shot_id": "image.jpg", + "projection": [0.1, -0.2] + } + ] + } + ] +} +``` + +Fields: + +- **`crs`** *(optional, default `WGS84`)* — coordinate reference system of the point positions. Accepts `WGS84`, `EPSG:xxxx`, a compound EPSG (e.g. `EPSG:2056+5773` for an explicit vertical datum), or a raw `+proj=…` string. A *projected* `crs` also sets the reconstruction's [output coordinate system](georeferencing.md#the-output-coordinate-system). +- **`id`** — identifier of the point; observations sharing an `id` belong to the same physical landmark. +- **`role`** *(optional, default `gcp`)* — `gcp` or `checkpoint` (see above). +- **`position`** — either geographic `latitude`/`longitude` (degrees) **or** projected `easting`/`northing` (in `crs` units, transformed internally to WGS84). `altitude` is optional (the point is treated as horizontal-only when omitted). The optional `latitude_std` / `longitude_std` / `altitude_std` are positional standard deviations in **metres** — all three must be present to take effect, and they override the global `gcp_horizontal_sd` / `gcp_vertical_sd` for this point. +- **`observations`** — list of `{shot_id, projection}`. `projection` is in [normalized image coordinates](geometry.md#normalized-image-coordinates). + +> A `coordinates` array (topocentric `[x, y, z]`) may also appear on a point. It is written by the pipeline and is not needed in hand-authored files. + +## TXT Format + +Add a file named `gcp_list.txt` at the dataset root: + +- **First line**: the CRS / projection string. +- **Following lines**: one observation per line — + + ``` + [] + ``` + + - ` ` are **pixel** coordinates (not normalized). + - `` may be `NaN` when the altitude is unknown. + - `` (optional) names the point: lines sharing the same name are grouped into one GCP. When omitted, observations are grouped by identical 3-D position and the point is auto-named `unnamed-N`. + + `#` comments and blank lines are ignored. The TXT format carries neither roles nor per-point standard deviations — use the JSON format for those. + +**Supported CRS strings** (same set for the JSON `crs` and the TXT header line): + +- `WGS84`: `geo_x = longitude`, `geo_y = latitude`, `geo_z = altitude` +- `WGS84 UTM 32N` (or any UTM zone): `geo_x = easting`, `geo_y = northing`, `geo_z = altitude` +- Any valid [proj4](http://proj4.org/) string, e.g. `+proj=utm +zone=32 +north +ellps=WGS84 +datum=WGS84 +units=m +no_defs` + +**Example `gcp_list.txt`** (two points, two observations each): + +``` +WGS84 +13.400740745 52.519134104 12.0792090446 2335.0 1416.7 01.jpg corner_A +13.400740745 52.519134104 12.0792090446 2639.1 938.0 02.jpg corner_A +13.400502446 52.519251158 16.7021233002 766.0 1133.1 01.jpg corner_B +13.400502446 52.519251158 16.7021233002 1090.2 1499.0 02.jpg corner_B +``` diff --git a/doc/source/images/camera.png b/doc/images/camera.png similarity index 100% rename from doc/source/images/camera.png rename to doc/images/camera.png diff --git a/doc/images/dense.png b/doc/images/dense.png new file mode 100644 index 000000000..7bab71044 Binary files /dev/null and b/doc/images/dense.png differ diff --git a/doc/images/dsm_ortho.png b/doc/images/dsm_ortho.png new file mode 100644 index 000000000..07df0b5f2 Binary files /dev/null and b/doc/images/dsm_ortho.png differ diff --git a/doc/source/images/feat1.png b/doc/images/feat1.png similarity index 100% rename from doc/source/images/feat1.png rename to doc/images/feat1.png diff --git a/doc/source/images/feat2.png b/doc/images/feat2.png similarity index 100% rename from doc/source/images/feat2.png rename to doc/images/feat2.png diff --git a/doc/source/images/gps.png b/doc/images/gps.png similarity index 100% rename from doc/source/images/gps.png rename to doc/images/gps.png diff --git a/doc/source/images/id-rotation.png b/doc/images/id-rotation.png similarity index 100% rename from doc/source/images/id-rotation.png rename to doc/images/id-rotation.png diff --git a/doc/images/logo.png b/doc/images/logo.png new file mode 100644 index 000000000..38dd6ca88 Binary files /dev/null and b/doc/images/logo.png differ diff --git a/doc/images/logo_small.png b/doc/images/logo_small.png new file mode 100644 index 000000000..06789a892 Binary files /dev/null and b/doc/images/logo_small.png differ diff --git a/doc/source/images/processing.png b/doc/images/processing.png similarity index 100% rename from doc/source/images/processing.png rename to doc/images/processing.png diff --git a/doc/source/images/rec.png b/doc/images/rec.png similarity index 100% rename from doc/source/images/rec.png rename to doc/images/rec.png diff --git a/doc/images/report.pdf b/doc/images/report.pdf new file mode 100644 index 000000000..54f413208 Binary files /dev/null and b/doc/images/report.pdf differ diff --git a/doc/source/images/reprojection_error.jpg b/doc/images/reprojection_error.jpg similarity index 100% rename from doc/source/images/reprojection_error.jpg rename to doc/images/reprojection_error.jpg diff --git a/doc/images/rerun.png b/doc/images/rerun.png new file mode 100644 index 000000000..d3454ea96 Binary files /dev/null and b/doc/images/rerun.png differ diff --git a/doc/source/images/residual_histogram.png b/doc/images/residual_histogram.png similarity index 100% rename from doc/source/images/residual_histogram.png rename to doc/images/residual_histogram.png diff --git a/doc/source/images/residuals.png b/doc/images/residuals.png similarity index 100% rename from doc/source/images/residuals.png rename to doc/images/residuals.png diff --git a/doc/source/images/rig_frame.png b/doc/images/rig_frame.png similarity index 100% rename from doc/source/images/rig_frame.png rename to doc/images/rig_frame.png diff --git a/doc/source/images/summary.png b/doc/images/summary.png similarity index 100% rename from doc/source/images/summary.png rename to doc/images/summary.png diff --git a/doc/source/images/time.png b/doc/images/time.png similarity index 100% rename from doc/source/images/time.png rename to doc/images/time.png diff --git a/doc/source/images/topview.png b/doc/images/topview.png similarity index 100% rename from doc/source/images/topview.png rename to doc/images/topview.png diff --git a/doc/source/images/tracks.png b/doc/images/tracks.png similarity index 100% rename from doc/source/images/tracks.png rename to doc/images/tracks.png diff --git a/doc/images/viewer.png b/doc/images/viewer.png new file mode 100644 index 000000000..f79fd351b Binary files /dev/null and b/doc/images/viewer.png differ diff --git a/doc/large_datasets.md b/doc/large_datasets.md new file mode 100644 index 000000000..b9ca6af6b --- /dev/null +++ b/doc/large_datasets.md @@ -0,0 +1,91 @@ +# Large Datasets + +## Splitting a Large Dataset into Submodels + +Large datasets can be slow to process. An option is to split them into smaller *submodels* that run faster (fewer images per bundle adjustment) and can be reconstructed in parallel. + +Since submodel reconstructions are done independently, they will not be aligned with each other. Only GPS positions and ground control points determine alignment. When neighboring reconstructions share cameras or points, the alignment of those can be enforced. + +## Creating Submodels + +The `create_submodels` command splits a dataset based on GPS positions. `extract_metadata` must be run first. Feature extraction and matching can also be run before splitting so submodels can reuse computed data: + +```bash +bin/opensfm extract_metadata path/to/dataset +bin/opensfm detect_features path/to/dataset +bin/opensfm match_features path/to/dataset +bin/opensfm create_submodels path/to/dataset +``` + +### Submodel Folder Structure + +Submodels are created in the `submodels/` folder. Each submodel is a valid OpenSfM dataset. Images, EXIF metadata, features, and matches are shared via symbolic links. + +``` +project/ +├── images/ +├── image_list.txt +├── image_list_with_gps.csv +├── exif/ +├── features/ +├── matches/ +└── submodels/ + ├── clusters_with_neighbors.geojson + ├── clusters_with_neighbors.npz + ├── clusters.npz + ├── image_list_with_gps.tsv + ├── submodel_0000/ + │ ├── image_list.txt + │ ├── config.yaml + │ ├── images/ # symlink to global + │ ├── exif/ # symlink to global + │ ├── features/ # symlink to global + │ ├── matches/ # symlink to global + │ ├── camera_models.json # symlink to global + │ └── reference_lla.json # symlink to global + ├── submodel_0001/ + └── ... +``` + +### Configuration Parameters + +- **`submodel_size`**: Average number of images per submodel. K-means clustering is used with `k = num_images / submodel_size`. +- **`submodel_overlap`**: Radius of the overlapping region between submodels (in meters). Images closer to a neighboring cluster than this value are added to both. +- **`submodels_relpath`**: Relative path to the submodels directory. +- **`submodel_relpath_template`**: Template for the relative path to a submodel directory. +- **`submodel_images_relpath_template`**: Template for the relative path to a submodel images directory. + +### Providing Image Groups Manually + +If you already know how to split the dataset, provide `image_groups.txt` in the main dataset folder with one line per image: + +``` +01.jpg A +02.jpg A +03.jpg B +04.jpg B +05.jpg C +``` + +This creates 3 submodels. The `create_submodels` command will still add overlap images based on `submodels_overlap`. + +## Running Reconstruction for Each Submodel + +Each submodel is a valid OpenSfM dataset. Assuming features and matches are already computed: + +```bash +bin/opensfm create_tracks path/to/dataset/submodels/submodel_XXXX +bin/opensfm reconstruct path/to/dataset/submodels/submodel_XXXX +``` + +These can be run in parallel since submodels are independent. + +## Aligning Submodels + +Once every submodel has a reconstruction, align them with: + +```bash +bin/opensfm align_submodels path/to/dataset +``` + +This loads all reconstructions, finds cameras and points shared between them, and rigidly transforms each reconstruction to best align the common elements. diff --git a/doc/merging_notes.md b/doc/merging_notes.md new file mode 100644 index 000000000..8d51821ba --- /dev/null +++ b/doc/merging_notes.md @@ -0,0 +1,74 @@ +# Notes on Multiple Reconstructions Alignment + +## Merging + +Given a set of reconstructions, let + +$$H_a = \begin{pmatrix} s_a R_a & t_a \\ 0 & 1 \end{pmatrix}$$ + +be the similarity transform mapping points in the global merged reference frame to the local reference frame of reconstruction $a$. + +Let $P_{ai} = (R_{ai}\ t_{ai})$ be the projection matrix of camera $i$ in reconstruction $a$, and $P_i = (R_i\ t_i)$ be the projection matrix of camera $i$ in the global reference frame. + +The relation between local and global camera positions is: + +$$P_i \propto P_{ai} H_a$$ + +so: + +$$s_a (R_i\ t_i) = (R_{ai}\ t_{ai}) \begin{pmatrix} s_a R_a & t_a \\ 0 & 1 \end{pmatrix}$$ + +Solving for $R_{ai}$ and $t_{ai}$: + +$$R_{ai} = R_i R_a^T$$ +$$t_{ai} = s_a t_i - R_i R_a^T t_a$$ + +To find the best absolute projections given the relative ones, minimize: + +$$\left\| \log(R_{ai} R_a R_i^T) \right\|^2_{\Sigma_{R_{ai}}} + \left\| t_{ai} - s_a t_i + R_i R_a^T t_a \right\|^2_{\Sigma_{t_{ai}}}$$ + +with respect to $\{(R_a,\ t_a)\}$. + +Alternatively, optimize rotation and translation jointly: + +$$\left\| \left(\log(R_{ai} R_a R_i^T),\ t_{ai} - s_a t_i + R_i R_a^T t_a \right) \right\|^2_{\Sigma_{Rt_{ai}}}$$ + +### Aligning Camera Centers Instead of Translations + +Aligning translation vectors directly is problematic: when rotations are misaligned, cameras may end up in different positions even with identical translation vectors. A more robust approach minimizes the distance between optical centers. + +Let the optical center of camera $i$ be $o_i = -R_i^T t_i$, and in reconstruction $a$: $o_{ai} = -R_{ai}^T t_{ai}$. + +After applying $H_a$, the centers should align: + +$$s_a R_a o_i + t_a = o_{ai}$$ + +Minimize: + +$$\left\| s_a R_a o_i + t_a - o_{ai} \right\|^2_{\Sigma_{o_{ai}}}$$ + +which in terms of $R$s and $t$s is: + +$$\left\| R_{ai}^T t_{ai} - s_a R_a R_i^T t_i + t_a \right\|^2_{\Sigma_{o_{ai}}}$$ + +### Camera Position Prior + +The camera center of camera $i$ is $-R_i^T t_i$. To keep it close to GPS position $g_i$, minimize: + +$$\left\| g_i + R_i^T t_i \right\|^2_{\Sigma_{g_i}}$$ + +If camera $i$ is not part of the optimization parameters, add the constraint in terms of $H_a$: + +$$\left\| g_i + (R_{ai} R_a)^T (R_{ai} t_a + t_{ai}) / s_a \right\|^2_{\Sigma_{g_i}}$$ + +### Common Point Constraint + +When a point is present in more than one reconstruction, the multiple reconstructions of that point should align. For every pair of reconstructions, let $p_{ai}$ and $p_{bi}$ be point $i$ in reconstructions $a$ and $b$ respectively. + +In the global reference frame they should agree: + +$$s_a^{-1} R_a^T (p_{ai} - t_a) = s_b^{-1} R_b^T (p_{bi} - t_b)$$ + +Minimize the difference: + +$$\left\| s_a^{-1} R_a^T (p_{ai} - t_a) - s_b^{-1} R_b^T (p_{bi} - t_b) \right\|_{\Sigma_p}$$ diff --git a/doc/quality_report.md b/doc/quality_report.md new file mode 100644 index 000000000..436c3ff5d --- /dev/null +++ b/doc/quality_report.md @@ -0,0 +1,82 @@ +# Statistics and Quality Report + +The `compute_statistics` command generates a JSON statistics file. The `export_report` command exports it as a PDF report. The report is broken into several subsections documented below. + +## Dataset Summary + +![Dataset Summary](images/summary.png) + +- **Dataset**: name of the dataset's folder +- **Date**: day and time at which `reconstruction.json` was created by the `reconstruct` command +- **Area Covered**: area covered by the bounding box enclosing all cameras +- **Processing Time**: total time for SfM processing (`detect_features`, `match_features`, `create_tracks`, `reconstruct`) + +## Processing Summary + +![Processing Summary](images/processing.png) + +- **Reconstructed Images**: reconstructed images / total images +- **Reconstructed Points**: reconstructed points / total points in `tracks.csv` +- **Reconstructed Components**: number of continuously reconstructed sets of images +- **Detected Features**: median number of detected features across images +- **Reconstructed Features**: median number of reconstructed features across images +- **Geographic Reference**: whether GPS and/or GCP were used for geo-alignment +- **GPS / GCP errors**: GPS and/or GCP RMS errors + +![Topview](images/topview.png) + +The top-view map shows GPS coordinates of images as blue points and actual reconstructed positions in red. Lines link images in capture order. Reconstructed points are visible as true-color points. + +## Features Details + +![Feature heatmap 1](images/feat1.png) + +The heatmap shows the density of detected features: blue for none, yellow for the most. + +![Feature heatmap 2](images/feat2.png) + +The table lists min/max/mean and median detected and reconstructed features across images. + +## Reconstruction Details + +![Reconstruction details](images/rec.png) + +- **Average reprojection error (normalized/pixels)**: normalized and pixel-wise norms of reprojection errors. Errors larger than 4 pixels are pruned. +- **Average Track Length**: average number of images a reconstructed point is detected in. +- **Average Track Length (> 2)**: same, ignoring points seen in only two images. + +![Residual histogram](images/residual_histogram.png) + +Histogram of normalized and un-normalized reprojection error norms. Errors larger than 4 pixels are pruned. + +## Tracks Details + +![Tracks](images/tracks.png) + +The graph shows image coordinates (GPS-based). Links are colored by number of common detected points between image pairs: yellow for few, blue for the most. Dots are colored by connected component. + +The table is a histogram of reconstructed points vs. number of image detections. + +## Camera Models Details + +![Camera models](images/camera.png) + +For each camera model: initial values from `camera_models.json` and optimized values from `reconstruction.json`. + +![Residuals](images/residuals.png) + +For each camera model: a grid of reprojection errors averaged by fixed-size cells, shown as scaled arrows. Color indicates error norm: yellow for larger, blue for smaller. + +## GPS/GCP Errors Details + +![GPS/GCP errors](images/gps.png) + +The table lists: +- Per-coordinate (X, Y, Z): sum of errors, median, and standard deviation +- Per-coordinate and total RMS (root mean squared) errors + +## Processing Time Details + +![Processing time](images/time.png) + +Time breakdown of each SfM step. diff --git a/doc/quickstart.md b/doc/quickstart.md new file mode 100644 index 000000000..661d4eb00 --- /dev/null +++ b/doc/quickstart.md @@ -0,0 +1,73 @@ +# Quickstart + +## Reconstructing the Example Dataset + +An example dataset is available at `data/berlin`. Reconstruct it by running: + +```bash +bin/opensfm_run_all data/berlin +``` + +This runs the entire SfM pipeline and produces `data/berlin/reconstruction.meshed.json` as output. + +## Running in Docker + +First, build the OpenSfM Docker image as described in [building](building.md). + +Start a Docker container, mounting the `data/` folder: + +```bash +docker run -it -p 8080:8080 -v ${PWD}/data/:/data/ opensfm.ubuntu24 /bin/bash +``` + +Inside the container, run the reconstruction: + +```bash +bin/opensfm_run_all /data/berlin/ +``` + +When done, exit with Ctrl+d. The model will be available in the `data/` directory. + +## Viewer + +A web-based viewer is included. Start it with: + +```bash +python viewer/server.py -d path/to/dataset +``` + +## Dense Point Clouds + +For a denser point cloud: + +```bash +bin/opensfm undistort data/berlin +bin/opensfm dense_clustering data/berlin +bin/opensfm compute_depthmaps data/berlin +bin/opensfm fuse_depthmaps data/berlin +bin/opensfm dense_merging data/berlin +``` + +This runs dense multi-view stereo and produces a dense point cloud at `data/berlin/undistorted/depthmaps/fused.ply` (along with a `mesh.ply`, a DSM and an orthophoto). Visualize the cloud with [MeshLab](http://www.meshlab.net/) or any viewer supporting [PLY](http://paulbourke.net/dataformats/ply/) files. + +## Reconstructing Your Own Images + +1. Put images in `data/DATASET_NAME/images/` +2. Optionally copy `data/berlin/config.yaml` to `data/DATASET_NAME/config.yaml` +3. Run the full pipeline: + +```bash +bin/opensfm_run_all data/DATASET_NAME +``` + +Or run steps individually (see [pipeline commands](using.md)): + +```bash +bin/opensfm extract_metadata data/DATASET_NAME +bin/opensfm detect_features data/DATASET_NAME +bin/opensfm match_features data/DATASET_NAME +bin/opensfm create_tracks data/DATASET_NAME +bin/opensfm reconstruct data/DATASET_NAME +``` + +See [dataset structure](dataset.md) for the expected folder layout and [configuration reference](configuration.md) for tuning options. diff --git a/doc/reconstruction.md b/doc/reconstruction.md new file mode 100644 index 000000000..bf9b916cd --- /dev/null +++ b/doc/reconstruction.md @@ -0,0 +1,45 @@ +# Incremental Reconstruction Algorithm + +OpenSfM implements an incremental structure-from-motion algorithm. It starts from a single image pair and iteratively adds other images one at a time. + +The algorithm is in `opensfm/reconstruction.py` and the main entry point is `incremental_reconstruction()`. + +The algorithm has three main steps: + +1. Find good initial pairs +2. Bootstrap the reconstruction with two images +3. Grow the reconstruction by adding images one at a time + +If images remain unreconstructed after step 3, steps 2 and 3 are repeated to generate additional reconstructions. + +## 1. Finding Good Initial Pairs + +To initialize from two images, there must be enough parallax between them (the camera must have moved significantly relative to the scene distance). + +To find sufficient parallax, a rotation-only camera model is fit to each pair. A pair is accepted only if a significant portion of correspondences cannot be explained by pure rotation — specifically, if the outlier fraction exceeds 30%. + +Accepted pairs are sorted by number of outliers (descending). Implemented in `compute_image_pairs()`. + +## 2. Bootstrapping the Reconstruction + +The first accepted image pair is used to initialize. If initialization fails, the next pair is tried. + +Two algorithms are available depending on scene geometry: +- **Flat scene**: plane-based initialization +- **Non-flat scene**: five-point algorithm + +Since the geometry is unknown a priori, both are computed and the one producing more points is retained (`two_view_reconstruction_general()`). + +If the pair produces enough inliers, a reconstruction is initialized with the computed poses, the matches are triangulated, and bundle adjustment is performed. + +## 3. Growing the Reconstruction + +Starting from the initial two-image reconstruction, images are added one by one — starting with the image that sees the most already-reconstructed points. + +For each new image: + +1. **Resectioning** (`resect()`): finds the camera position that makes reconstructed 3D points project to their corresponding locations in the new image. +2. If resectioning succeeds, the image is added and its features that are visible in other reconstructed images are triangulated. +3. **Bundle adjustment** and **re-triangulation** are run as needed. The parameters `bundle_interval`, `bundle_new_points_ratio`, `retriangulation`, and `retriangulation_ratio` control when these are triggered. + +Finally, if GPS positions or Ground Control Points are available, the reconstruction is rigidly aligned to them. diff --git a/doc/reporting.md b/doc/reporting.md new file mode 100644 index 000000000..d40e0cbe3 --- /dev/null +++ b/doc/reporting.md @@ -0,0 +1,105 @@ +# Reporting + +OpenSfM commands write reports on the work done. Reports are stored in the `reports/` folder in JSON format. + +## Feature Detection + +Stored in `features.json`: + +```json +{ + "wall_time": "", + "image_reports": [ + { + "wall_time": "", + "image": "", + "num_features": "" + } + ] +} +``` + +## Matching + +Stored in `matches.json`: + +```json +{ + "wall_time": "", + "pairs": "", + "num_pairs": "", + "num_pairs_distance": "", + "num_pairs_time": "", + "num_pairs_order": "" +} +``` + +## Create Tracks + +Stored in `tracks.json`: + +```json +{ + "wall_time": "", + "wall_times": { + "load_features": "