A Python-based pipeline for reconstructing photogrammetry datasets using OpenMVG and OpenMVS.
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-objectUpon successful completion of the pipeline, your reconstructed model can be
found in recon/mvs/my-object.obj.
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 fromUpgrading 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.
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 textureThe 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-runresolves 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
--rerunis given. So retrying just the expensive step ispgs-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-densifyto 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
--fromare 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>.objfilename in the meantime, so check the warning rather than the filename. - What is on disk is never consulted —
<output>/pgs-recon.jsonis the record. If you delete an intermediate by hand, use--rerunto 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:
--pathand--cam-dbapply silently, and--threads,--log-level,--configand--outputare not recorded at all, so they never leak into a later job. --outputmust be on a filesystem every job can see.pgs-recondoes no copying of its own; stage node-local scratch in and out around it.--no-mvsis deprecated: use--to colorizefor an SfM-only run. The old flag still works (it sets--to colorizeand warns) but will be removed.
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 64Both 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.
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.0CUDA-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.8All 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]
...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).
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.47Key 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 withpgs-center --load-transformandpgs-calibrate/pgs-retexture --sfm-transform.-s, --marker-size— marker size in the desired world units (required unless--no-scaleor--orient-method bbox).--orient-method {auto,aruco,bbox}— orientation source (defaultauto: use markers if detected, otherwise fall back to the mesh bounding box).arucofails if no markers are found;bboxignores 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.
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);fyand thek*distortion are optional, and anyposeis 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-lengthin pixels, or--focal-length-mmtogether 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, withk*emitted only when non-zero). It is consumed by registration-toolkit and can be fed straight back into--intrinsic.
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.
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 --helpThe 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-objectEvery 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.
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/ ..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 CGALDocker 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.
Apptainer images can be built by running the following from the root of the project directory:
apptainer build pgs-recon.sif apptainer/pgs-recon.defBy 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