Skip to content

Repository files navigation

PGS Recon

A Python-based pipeline for reconstructing photogrammetry datasets using OpenMVG and OpenMVS.

Usage

The simplest way to get started is to pull the Docker image of this project and run pgs-recon on your directory of images:

# Download the image
docker pull ghcr.io/educelab/pgs-recon:latest

# Run reconstruction on a directory of images in the current working directory
# Flags:
#  -v .:/working       - Mounts the current working directory to '/working' 
#                        inside the container
#  -i /working/images  - Path to the images inside the container
#  -o /working/recon   - Output directory inside the container
#  --name my-object    - Descriptive name for the scanned object/scene. This is 
#                        used to name the output file. If not provided, defaults
#                        to a name derived from the current timestamp and the 
#                        name of the input directory
docker run -v .:/working ghcr.io/educelab/pgs-recon:latest \
  pgs-recon -i /working/images/ -o /working/recon/ --name my-object

Upon successful completion of the pipeline, your reconstructed model can be found in recon/mvs/my-object.obj.

What lands in the output directory

Every intermediate is named <stage>_<role>, after the stage that produced it, so a half-finished directory can be read for what has happened so far. Optional stages are marked; the rest are always present:

recon/
  pgs-recon.json                  # the manifest: what ran, and with what arguments
  my-object_recon_config.txt      # the effective arguments, loadable with -c
  mvg/
    sfm_data.json                 # the imported scene
    matches_dir/                  # per-image features, matches[_filtered].bin
    recon_dir/
      sfm_data.bin                # the solve
      robust_sfm.bin              # --mvg-robust
      autoscale_sfm.bin           # --mvg-autoscale
      landmarks[_scaled].ply      # --mvg-autoscale: the markers it scaled from
      colorize_sfm.ply            # sparse cloud coloured from the images
  mvs/
    convert_scene.mvs             # the interface scene every MVS stage reads
    undistorted_images/
    densify.mvs  densify.ply      # --mvs-densify: scene + the dense cloud
    reconstruct_mesh.ply
    refine_mesh.ply               # --mvs-refine (on by default)
    my-object.obj                 # the deliverable, + .mtl and texture image

Locate an artifact through the manifest, not by rebuilding its name. Every stage records the paths it consumed and produced, relative to the output directory, and those records are what a resumed job reads — which is what lets these names change without invalidating a directory that already exists:

jq -r '.stages.texture.outputs.mesh' recon/pgs-recon.json   # the textured mesh
jq -r '.stages.convert.inputs.sfm'   recon/pgs-recon.json   # the solved SfM it came from

Upgrading from 1.7, where the manifest was metadata.json and intermediates were named by chaining (scene_dense_refine.ply)? Those directories are still read, but a 1.7 manifest carries no per-stage record, so a run against one rebuilds it from the start. See docs/migrating-to-2.0.md.

Staged and resumable runs

The pipeline records what it has finished in <output>/pgs-recon.json, so re-running the same command in the same output directory resumes it rather than starting over. After a crash or an out-of-memory kill during mesh refinement, this picks up at refine:

pgs-recon -i images/ -o recon/ --name my-object

--from/--to (both inclusive) restrict a run to a contiguous window of the thirteen pipeline stages:

import  features  matches  filter  sfm  robust  autoscale  colorize
convert  densify  reconstruct  refine  texture

This lets one reconstruction be split across several cluster jobs, each sized for the stages it runs — useful because RefineMesh needs far more memory than the rest of the pipeline, and sizing a whole-pipeline job for its worst case wastes a large allocation on hours of cheap SfM:

J1=$(sbatch --mem=32G  --parsable job1.sh)   # pgs-recon -i $IMGS -o $OUT -n obj --to reconstruct
J2=$(sbatch --mem=256G --parsable --dependency=afterok:$J1 job2.sh)  # pgs-recon -o $OUT --from refine --to refine
        sbatch --mem=64G           --dependency=afterok:$J2 job3.sh  # pgs-recon -o $OUT --from texture

The later jobs need neither -i nor --name: every argument of the first run is recorded in the manifest and reloaded, so only what changes has to be repeated. apptainer/submit_recon_pipeline.sh is a worked example of this: it submits the OpenMVG stages to a CPU node, densification to a GPU node, and mesh/refine/texture to a high-memory node, chained with afterok. Notes:

  • --dry-run resolves and prints the whole plan — loaded arguments, pipeline shape, which stages will run or be skipped, rehydrated input paths, and the prerequisite check — then exits without launching a binary. With no range it doubles as a status query for an output directory.
  • Stages already recorded complete are skipped. A stage re-runs if its own arguments changed, if a stage producing one of its inputs re-runs, if one of its inputs now comes from somewhere else, or if --rerun is given. So retrying just the expensive step is pgs-recon -o recon/ --from refine --refine-resolution-level 2, which re-refines and re-textures but touches nothing before it.
  • Changing the shape is allowed at any point. Adding --mvs-densify to a finished reconstruction re-runs densify and the mesh stages, and dropping it again re-runs them against the sparse cloud. Filenames stay put either way: an artifact is named for the stage that wrote it, not for the stages upstream of it.
  • Stages before --from are never run implicitly: if one is incomplete or its inputs have moved, the run fails immediately, naming each, instead of quietly doing work the job was not sized for.
  • If the range stops before stages the run invalidates, those stages are named in a warning and rebuilt by the next run that covers them. The final textured mesh keeps its usual mvs/<name>.obj filename in the meantime, so check the warning rather than the filename.
  • What is on disk is never consulted — <output>/pgs-recon.json is the record. If you delete an intermediate by hand, use --rerun to rebuild it.
  • An argument aimed at a stage outside the range is ignored with a warning, because it would change what the stages in range consume, and this run is not sized to rebuild them. Per-invocation settings are exempt and can differ freely between jobs: --path and --cam-db apply silently, and --threads, --log-level, --config and --output are not recorded at all, so they never leak into a later job.
  • --output must be on a filesystem every job can see. pgs-recon does no copying of its own; stage node-local scratch in and out around it.
  • --no-mvs is deprecated: use --to colorize for an SfM-only run. The old flag still works (it sets --to colorize and warns) but will be removed.

When mesh refinement takes too long

refine is the pipeline's slowest and hungriest stage, and it can run out of two different resources. Out of memory is the familiar one, and resuming the same command picks up where the kill happened.

Out of wall clock looks different: no progress in the log, one core pinned at 100%, and memory flat. That is mesh preparation rather than the optimization — before refining anything, RefineMesh subdivides the input mesh and remeshes the result with single-threaded CGAL, which is silent at the default verbosity and on a mesh of a few hundred thousand vertices can run for tens of minutes or more. Adding cores or memory does not help. Turning it off does:

# Skip the remesh; refine everything else as before
pgs-recon -o recon/ --from refine --refine-ensure-edge-size 0

# Or subdivide less aggressively, so preparation has less to remesh
pgs-recon -o recon/ --from refine --refine-max-face-area 64

Both change the refined mesh, so they are options rather than defaults. If refine is not worth its cost on a given dataset, --no-mvs-refine drops it from the pipeline shape and textures the reconstructed mesh directly.

Docker images

We provide multi-architecture (x86, arm64) Docker images in the GitHub Container Registry. Simply pull our container and Docker will select the appropriate image for your host platform:

# Pull the latest release
docker pull ghcr.io/educelab/pgs-recon:latest

# Pull the latest edge version
docker pull ghcr.io/educelab/pgs-recon:edge

# Pull a specific version
docker pull ghcr.io/educelab/pgs-recon:2.0.0

CUDA-enabled images are available by appending -cudaX.X to any of the standard tags. We currently only provide images for CUDA 12.4 and 12.8:

# Pull the latest CUDA 12.4 release
docker pull ghcr.io/educelab/pgs-recon:latest-cuda12.4

# Pull the latest CUDA 12.8 release
docker pull ghcr.io/educelab/pgs-recon:latest-cuda12.8

All project tools can be launched directly using docker run:

$ docker run ghcr.io/educelab/pgs-recon pgs-recon --help
usage: pgs-recon [-h] [--config CONFIG] [--input INPUT] --output OUTPUT
                 [--name NAME] [--file-type {ply,obj}] [--focal-length n]
                 [--new-importer | --no-new-importer]
                 [--import-pgs-scan | --no-import-pgs-scan | -p]
                 [--import-calib IMPORT_CALIB]
...

Utilities

In addition to the main pgs-recon pipeline, this project ships several standalone tools. All of them can be launched through the Docker image in the same way as pgs-recon (e.g. docker run ... pgs-sfm-orient --help).

pgs-sfm-orient

Centers, orients, and (optionally) scales a reconstructed mesh using the EduceLab sample square / ArUco markers detected directly in the SfM scene images. It is the SfM-based counterpart to pgs-center: where pgs-center detects the sample square in the mesh's UV texture (which requires a coherent, reordered texture map), pgs-sfm-orient detects and triangulates the markers from the original images, so it works regardless of how the mesh was textured.

The translation comes from the mesh's oriented-bounding-box center (so the object lands at the origin), the orientation and scale come from the markers, and the result is written as a transformed mesh and/or a 4×4 similarity transform. The input mesh must already be in the SfM coordinate frame.

docker run -v .:/working ghcr.io/educelab/pgs-recon \
  pgs-sfm-orient \
    -i /working/recon/mvg/recon_dir/sfm_data.bin \
    --input-mesh /working/recon/mvs/my-object.obj \
    -o /working/recon/mvs/my-object-centered.obj \
    --save-transform /working/recon/orient.npy \
    -s 0.47

Key options:

  • -i, --input-scene — the SfM scene file (markers are detected in its images).
  • --input-mesh — mesh (.obj/.ply) in the SfM frame; enables OBB-center translation and the bounding-box orientation fallback.
  • -o, --output-mesh — write the transformed mesh (requires --input-mesh).
  • --save-transform — write the 4×4 transform as a NumPy .npy, compatible with pgs-center --load-transform and pgs-calibrate/pgs-retexture --sfm-transform.
  • -s, --marker-size — marker size in the desired world units (required unless --no-scale or --orient-method bbox).
  • --orient-method {auto,aruco,bbox} — orientation source (default auto: use markers if detected, otherwise fall back to the mesh bounding box). aruco fails if no markers are found; bbox ignores markers and requires a mesh.
  • --no-scale — skip scale estimation (output rotation + translation only).

At least one of --output-mesh or --save-transform is required.

Camera calibration file format

pgs-calibrate reads and writes camera parameters in a single plain-text camera calibration file. It is a flat list of key value entries, one per line; blank lines and lines beginning with # are ignored, and unrecognized keys are skipped (so the same file can carry both an intrinsic and a pose, and each consumer reads only what it needs).

Key Meaning
fx, fy Focal length in pixels (x and y). fy defaults to fx if omitted. OpenMVG uses a single focal, so the two should match.
cx, cy Principal point in pixels.
width, height Image resolution (pixels) the intrinsic is calibrated at.
k1, k2, k3 Radial distortion coefficients (OpenCV/OpenMVG order). Optional; absent means no distortion.
pose 16 whitespace-separated floats: a row-major 4×4 world-to-camera matrix in OpenCV convention (x_cam = R·X + t).

Example (an overhead camera with mild barrel distortion):

# my overhead RGB camera
fx 18250.0
fy 18250.0
cx 3000.0
cy 2000.0
width 6000
height 4000
k1 -0.082
k2 0.011
k3 0.0
pose 0.9998 0.0011 -0.0203 12.4 -0.0009 0.9999 0.0102 -8.1 0.0203 -0.0102 0.9997 423.7 0 0 0 1

Two flags use this format:

  • pgs-calibrate --intrinsic <file> reads it as a precalibrated query intrinsic. It requires the intrinsic keys (fx, cx, cy, width, height); fy and the k* distortion are optional, and any pose is ignored (the pose is what calibration solves for). The intrinsic is scaled to the query image's resolution automatically, and the distortion is honored — OpenMVG undistorts the query before resectioning. This is the stable, recommended path for long-focal overhead cameras with few feature matches. (For a focal-only calibration you can instead pass --focal-length in pixels, or --focal-length-mm together with --pixel-size (mm/px) or --sensor-width (mm); both assume a centered principal point and no distortion.)
  • pgs-calibrate --save-camera-file <file> writes the solved calibration in this format (intrinsic + pose, with k* emitted only when non-zero). It is consumed by registration-toolkit and can be fed straight back into --intrinsic.

Install from source

Install dependencies

The Python scripts use executables provided by the OpenMVG and OpenMVS projects. The included CMake project will compile both of these projects and their dependencies. Before configuring the CMake project, please preinstall the following dependencies:

  • CMake 3.17+
  • Boost 1.70+
  • GMP and MPFR
  • ExifTool
  • (Optional) NASM (Required by jpeg-turbo)
  • (Optional) Ceres Solver
  • (Optional) CUDA Toolkit

After the dependencies have been installed, configure and build the CMake project to compile the required executables:

cmake -S dependencies -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build/

Notes:

  • OpenMVS and OpenCV must be linked against the same version of libjpeg.

Install the Python pipeline scripts

Use a recent version of pip to install the Python scripts:

# Requires Python 3.9+
python3 -m pip install .

After installation, the reconstruction script can be run from the shell:

pgs-recon --help

Telling the tools where the binaries are

The Python pipeline shells out to the compiled OpenMVG/OpenMVS/pgs-* binaries, which it looks for under an install prefix containing bin/ (OpenMVG and our own tools) and bin/OpenMVS/. Inside our Docker/Apptainer images that prefix is /usr/local/, which is the default, so nothing needs setting. Elsewhere — most often a CMake build left in its default dependencies/installed/ — point $PGS_RECON_PREFIX at it:

export PGS_RECON_PREFIX="$PWD/dependencies/installed"
pgs-recon -i images/ -o recon/ --name my-object

Every entry point (pgs-recon, pgs-retexture, pgs-calibrate) reads it, and each also takes a --path <prefix> argument that wins over the environment. A missing binary is reported with the path that was searched and which of the three tiers chose the prefix — the argument, the environment, or the built-in default — so a typo in any of them is unambiguous. The OpenMVG camera sensor database is expected at <prefix>/lib/openMVG/sensor_width_camera_database.txt.

Unlike most arguments, --path is deliberately not inherited from a previous run's manifest when a staged run resumes (see --from/--to above), so each job of a split reconstruction picks up the prefix of the node it lands on.

Advanced Installation

Installation Location

By default, executables created by this CMake project will be installed to dependencies/installed/. The installation location can be changed by setting the CMake installation prefix flag:

cmake -DCMAKE_INSTALL_PREFIX=/usr/local/ ..

Disable compilation of extra libraries

In addition to VCG, OpenMVG, and OpenMVS, the CMake project also compiles a number of required software libraries. We provide corresponding CMake flags to control the compilation of these libraries. To use a system-provided version of these libraries, set the library's flag to OFF:

BUILD_EIGEN: If ON, builds Eigen 3.2
BUILD_JPEG: If ON, builds libjpeg
BUILD_JPEG_TURBO: If ON, builds libjpeg-turbo (depends BUILD_JPEG=ON)
BUILD_OPENCV: If ON, builds OpenCV
BUILD_CGAL: If ON, builds CGAL

Building a Docker image

Docker images can be built by running the following from the root of the project directory:

docker build -t pgs-recon:dev .

By default, this image only supports a CPU-based reconstruction pipeline. A CUDA-enabled image can be built by passing the BASE_IMAGE and USE_CUDA build args:

docker build -t pgs-recon:dev \
  --build-arg BASE_IMAGE=nvidia/cuda:12.4.1-devel-ubuntu22.04 \
  --build-arg USE_CUDA=ON \
  -t pgs-recon:dev-cuda \
  .

BASE_IMAGE should be an nvidia/cuda:*-devel-ubuntu* Docker image [link]. While this can theoretically be set to any Ubuntu and CUDA version, this has only been tested on:

  • CUDA 12.4, Ubuntu 22.04 (with and without CUDNN)
  • CUDA 12.8, Ubuntu 22.04 (with and without CUDNN)

USE_CUDA should be either ON or OFF [default]. If USE_CUDA=OFF, CUDA will not be used even if you provide a CUDA-enabled base image.

Building an Apptainer image

Apptainer images can be built by running the following from the root of the project directory:

apptainer build pgs-recon.sif apptainer/pgs-recon.def

By default, this image only supports a CPU-based reconstruction pipeline. A CUDA-enabled image can be built by passing the provided build args file for your required CUDA version:

apptainer build pgs-recon.sif \
  --build-arg-file apptainer/buildargs-cuda12.4.env \
  apptainer/pgs-recon.def

About

The EduceLab photogrammetry reconstruction pipeline

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages