The Ohio State University Β· * Corresponding author
Journal Β· ISPRS Journal of Photogrammetry and Remote Sensing, vol. 237, pp. 589β605, 2026
Per-point covariance estimation for aerial / UAV photogrammetric point clouds. For every point in the dense reconstruction, the pipeline produces a 3Γ3 error covariance matrix that propagates uncertainty through the full two-step photogrammetry process β Structure-from-Motion / Bundle Adjustment (SfM/BA) and Multi-View Stereo (MVS / Dense Image Matching).
This repository is the Python re-implementation of the framework. The paper's experimental results were produced with the in-house C++ MVS package MSP, which uses Semi-Global Matching (SGM) for dense matching. This repository ships an end-to-end Python pipeline that uses OpenCV SGBM for the dense matching step and re-implements the same uncertainty quantification framework on top of it. We therefore expect the numerical results produced here to be close to, but not bit-exact with, the figures reported in the paper.
The framework propagates uncertainty as
where
Paper Figure 2 β overview of the uncertainty quantification framework. SfM-stage covariance Ξ£_ΞΈ and MVS-stage disparity covariance Ξ£_disp are propagated through the projection-function Jacobians to a 3Γ3 covariance per 3D point.
scripts/run_mvs_pipeline.py implements, for each reference image and a set of co-visible neighbors:
-
Read COLMAP sparse model β
cameras.bin,images.bin,points3D.bin. -
Select stereo pairs from the co-visibility graph (or by SIFT matching with
--sift_graph). -
Pairwise dense matching with OpenCV SGBM β disparity map + matching-cost proxy.
-
Multi-view fusion β fuse per-pair depth maps into per-reference XYZ grids with a multi-view geometric-consistency check; a 3D point is kept only if observed in β₯ 3 views.
-
Disparity-uncertainty regression β using n-view points (
$n \ge 6$ ) as pseudo-check points, regress per-pixel disparity uncertainty$u$ from matching-cost cues (8 costβ$\sigma$ bins, interpolated per-pixel), then refine with the reprojection residual.Paper Figure 3 β (1) reliable n-view points are taken from the MVS reconstruction; (2) each is reprojected to every stereo pair, giving a residual r and a matching cost c; (3) per cost-bin residual std Ο_r is computed; (4) per-pixel disparity uncertainty u is interpolated between bins.
-
Propagation to 3D-point covariance β propagate disparity uncertainty (
$\Sigma_{\text{disp}}$ ) plus the SfM camera-parameter covariance ($\Sigma_\theta$ ) to a 3Γ3 covariance per 3D point via Eq. 1 above. -
Write outputs β per-image
*_cov.npz(xyz, 3Γ3 cov,$\sigma$ ),*_fused.las, plus dataset-widefused_all_cov.npzandfused_all.las.
A separate script, scripts/compute_sensor_error_prop.py, implements just the SfM-stage propagation (camera-parameter covariance β 3D-point covariance, i.e.
scripts/visualize_sensor_cov.py builds a self-contained Three.js HTML viewer that draws the
git lfs install # the example COLMAP poses are tracked in Git LFS
git clone https://github.com/GDAOSU/UncertaintyQuantification.git
cd UncertaintyQuantification
# Option A: conda
conda create -n uncertainty python=3.10 -y
conda activate uncertainty
pip install -r requirements.txt
# Option B: venv
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtIf you cloned without git-lfs, the examples/**/*.bin files will be plain-text pointer stubs β run git lfs pull after installing git-lfs to fetch them.
Required packages:
| Package | Notes |
|---|---|
| numpy, scipy | core math |
| opencv-python | SGBM stereo, image I/O |
| laspy | LAS point cloud output (and visualizer LAS overlay) |
| matplotlib | visualizer LAS-overlay polygon clipping |
| torch | only used by --sift_graph (GPU brute-force SIFT matching) |
| open3d | only used by evaluate_uncertainty.py (point-to-point ICP) |
A CUDA-capable GPU is optional but recommended for --sift_graph on large datasets.
UncertaintyQuantification/
βββ scripts/
β βββ run_mvs_pipeline.py # main entrypoint (steps 1β7 above)
β βββ compute_sensor_error_prop.py # SfM-stage propagation only (USfM/NBUP, Ξ£_disp=0)
β βββ rectify.py # stereo rectification helpers
β βββ colmap_io.py # COLMAP binary readers (SchΓΆnberger)
β βββ visualize_sensor_cov.py # interactive Three.js viewer
β βββ evaluate_uncertainty.py # ICP align + per-point distance + metrics
βββ examples/
β βββ Dortmund/sparse/ # COLMAP poses only β see "Examples"
β βββ UseGeo/Dataset-{1,2,3}/sparse/
βββ requirements.txt
βββ LICENSE # Apache 2.0
The examples/ folders contain only the COLMAP sparse/ triplet (cameras.bin, images.bin, points3D.bin). Source images must be downloaded separately β see below.
The example poses we ship were produced with COLMAP using the original FBK images, with the original filenames preserved. The shipped images.bin therefore references files such as backward_001_009_145000282.tif (Dortmund) or 2021-04-23_13-17-12_S2223314_DxO_res.jpg (UseGeo). Do not rename images after download β the pipeline matches by filename.
The Dortmund scene is part of the NeRFBK benchmark. It is a 59-image subset (nadir + oblique) captured with the IGI PentaCam (5 camera heads), at native resolution 6132Γ8176. Download from:
- https://github.com/3DOM-FBK/NeRFBK (under the Aerial β Dortmund section)
Place all 59 images into examples/Dortmund/images/:
examples/Dortmund/
βββ sparse/ # provided (LFS)
β βββ cameras.bin
β βββ images.bin
β βββ points3D.bin
βββ images/ # 59 .tif files you download
βββ backward_001_009_145000282.tif
βββ backward_001_010_145000281.tif
βββ ...
Run:
python scripts/run_mvs_pipeline.py \
--scene examples/Dortmund/sparse \
--images examples/Dortmund/images \
--out examples/Dortmund/out \
--scale 0.25 \
--workers 8 \
--sift_graphUseGeo is a UAV-based multi-sensor dataset with three flights covering different urban / peri-urban areas (Dataset-1 / Dataset-2 / Dataset-3). Download from:
Use the DxO color-corrected, resampled RGB images β filenames end in ..._DxO_res.jpg. Place them under each Dataset-N/images/:
examples/UseGeo/Dataset-1/
βββ sparse/ # provided (LFS)
βββ images/ # 224 .jpg files you download
βββ 2021-04-23_13-17-12_S2223314_DxO_res.jpg
βββ ...
Image counts in our shipped poses:
| Dataset | # images |
|---|---|
| Dataset-1 | 224 |
| Dataset-2 | 327 |
| Dataset-3 | 277 |
Run on Dataset-1:
python scripts/run_mvs_pipeline.py \
--scene examples/UseGeo/Dataset-1/sparse \
--images examples/UseGeo/Dataset-1/images \
--out examples/UseGeo/Dataset-1/out \
--scale 1.0 \
--workers 8 \
--sift_graphRepeat for Dataset-2 and Dataset-3.
Quick sanity check β Python reports any name in images.bin that is missing on disk:
python - <<'PY'
import sys; sys.path.insert(0, 'scripts')
from run_mvs_pipeline import read_images_bin
from pathlib import Path
sparse = Path('examples/UseGeo/Dataset-1/sparse/images.bin')
img_dir = Path('examples/UseGeo/Dataset-1/images')
names = {im['name'] for im in read_images_bin(sparse).values()}
missing = sorted(n for n in names if not (img_dir / n).exists())
print(f"{len(names)-len(missing)}/{len(names)} images present, {len(missing)} missing")
for n in missing[:5]:
print(" missing:", n)
PYIn the --out directory:
| File | Contents |
|---|---|
<image>_cov.npz |
per-image anchor points: xyz (N,3), gt_cov (N,3,3), sigma (N,), cam_xyz, cam_R |
<image>_fused.las |
per-image fused colored point cloud |
fused_all_cov.npz |
voxel-fused dataset-wide covariance point cloud |
fused_all.las |
voxel-fused colored point cloud (all images) |
mvs_pipeline_<timestamp>.log |
full DEBUG log for the run |
The 3Γ3 gt_cov field stores sigma field is the square root of the trace of sqrt(gt_cov[:,0,0] + gt_cov[:,1,1] + gt_cov[:,2,2]).
scripts/visualize_sensor_cov.py builds a self-contained Three.js HTML page for inspecting per-point covariance.
| Overview β points colored by Ο | Click a point β 3Ο ellipsoid |
|---|---|
![]() |
![]() |
Run it on the pipeline output:
python scripts/visualize_sensor_cov.py \
--npz examples/UseGeo/Dataset-1/out/fused_all_cov.npz \
--las examples/UseGeo/Dataset-1/out/fused_all.las \
--max_points 200000The script writes the HTML to a temp directory, starts a one-shot local HTTP server, and opens your browser.
Controls
- Left-drag rotate Β· Right-drag pan Β· Scroll zoom
- Click any point β the right panel shows its position, Ο_x / Ο_y / Ο_z and the 3Ο covariance ellipsoid is drawn at that point (toggle visibility / scale in the same panel)
- The bottom colorbar maps Ο β color (jet); points outside the active Ο range can be filtered with the slider above
Flags
--npz(required) β path to a*_cov.npzorfused_all_cov.npzproduced by the pipeline--las(optional) β companion*_fused.las/fused_all.lasfor native RGB overlay--max_pointsβ random subsample for performance (default 200 000;0disables)--portβ HTTP port (0= pick a free port automatically)
scripts/evaluate_uncertainty.py compares the predicted per-point uncertainty Ο = β(tr Ξ£_g) against ground-truth LiDAR. For every MVS point it computes the nearest-neighbor LiDAR distance d after ICP fine-alignment, and reports the same five metrics used in the paper's evaluation:
| Metric | Direction | Definition |
|---|---|---|
| pearson | β | corrcoef(Ο, d) β does Ο track the actual error |
| mean (MAE) | β | mean |Ο β d| |
| rmse | β | β(mean((Ο β d)Β²)) |
| kl_div | β | KL between 1-D Gaussians N(0, dΒ²) β N(0, ΟΒ²), averaged over points |
| bounded rate | β | percent of points where Ο > d (predicted 1Ο envelope contains the LiDAR surface) |
Ο-distribution and distance-distribution stats (min / median / max) are also printed and saved.
Run it on the pipeline output:
python scripts/evaluate_uncertainty.py \
--npz examples/UseGeo/Dataset-1/out/fused_all_cov.npz \
--lidar /path/to/UseGeo_dataset1_aligned.lasOutputs an eval.npz next to the input (xyz, Ο, distance, bounded mask, ICP transform, metrics) and prints metrics to stdout.
LiDAR point clouds are large (1.7β4 GB per dataset) and are not redistributed in this repo β download them from the upstream FBK pages:
| Dataset | Source | Notes |
|---|---|---|
| Dortmund | https://github.com/3DOM-FBK/NeRFBK (Aerial β Dortmund) | airborne LiDAR provided alongside imagery |
| UseGeo Dataset-1 / 2 / 3 | https://github.com/3DOM-FBK/UseGeo | airborne LiDAR + GT depth maps per dataset |
The COLMAP poses we ship were produced in a local frame (per-dataset bbox roughly centered around the origin). The LiDAR clouds released by FBK are in their original survey frame (typically absolute UTM). The two frames differ by a per-dataset translation of hundreds to thousands of meters.
Before running evaluate_uncertainty.py you must coarsely align the LiDAR to the MVS frame so that point correspondences fall within ICP's 2 m gating distance. Suggested workflow:
- Open the MVS
fused_all.las(output ofrun_mvs_pipeline.py) and the raw LiDAR side-by-side in CloudCompare / Open3D. - Read the bounding-box centers (or any common feature) of both clouds.
- Subtract the translation
(Ξx, Ξy, Ξz) = MVS_center β LiDAR_centerfrom the LiDAR XYZ. - (Optional) Visually verify in CloudCompare that they overlap. ICP handles small residuals afterwards.
- Save the aligned LiDAR back to a
.lasand pass it as--lidar.
If your LiDAR is already aligned to the MVS frame, ICP handles small residuals on its own; otherwise it will simply report fitness=0 and produce no useful metrics.
| Flag | Default | Effect |
|---|---|---|
--scale |
1.0 | downsample factor for images (e.g. 0.25 = quarter-res) |
--workers |
8 | parallel SGM workers β reduce for large images / low RAM |
--max_neighbors |
10 | stereo neighbors per reference image |
--sift_graph |
off | use SIFT matching instead of co-visibility for neighbor selection |
--voxel_size |
0.05 | edge length for final voxel fusion (in scene units) |
See python scripts/run_mvs_pipeline.py --help for the full list.
If you use this code, please cite:
@article{HUANG2026589,
title = {Uncertainty quantification framework for aerial and UAV photogrammetry through error propagation},
author = {Debao Huang and Rongjun Qin},
journal = {ISPRS Journal of Photogrammetry and Remote Sensing},
volume = {237},
pages = {589--605},
year = {2026},
issn = {0924-2716},
doi = {https://doi.org/10.1016/j.isprsjprs.2026.04.047},
url = {https://www.sciencedirect.com/science/article/pii/S0924271626002285}
}If you use scripts/compute_sensor_error_prop.py (SfM-stage propagation), please also acknowledge the USfM framework by Michal Polic β https://github.com/michalpolic/usfm.github.io.
Source code in this repository is released under the Apache License 2.0 β see LICENSE.
scripts/colmap_io.py is derived from the COLMAP read_write_model.py script
(Β© ETH ZΓΌrich / UNC Chapel Hill, BSD 3-clause); see the header in that file.
The BA-covariance formulation in scripts/compute_sensor_error_prop.py reproduces the math of the USfM / NBUP framework by Michal Polic (CTU Prague). The original USfM repository at https://github.com/michalpolic/usfm.github.io does not declare a license.
The COLMAP sparse/ files we ship under examples/ were produced from third-party imagery and are redistributed for research / non-commercial evaluation only:
| Source dataset | License | Upstream |
|---|---|---|
| Dortmund (NeRFBK) | CC-BY-NC-SA 4.0 | https://github.com/3DOM-FBK/NeRFBK |
| UseGeo Dataset-1/2/3 | CC-BY-NC-SA 4.0 | https://github.com/3DOM-FBK/UseGeo |
Both upstream datasets require attribution, prohibit commercial use, and require derivative works to use the same license. The Apache-2.0 license on this repository covers our code only β the example COLMAP poses inherit the upstream CC-BY-NC-SA 4.0 terms. Source images are not redistributed here; download them from the FBK repositories above.



