#4752·colmap

[Bug] 4.2.0: images.txt poses from a rig reconstruction do not match image.CamFromWorld() (stale per-image poses)

Author: adisonshadowCreated Sep 17, 2026Updated Sep 17, 2026
Labelsneeds attention

Description

When reconstructing a multi-camera rig (frames with multiple sensors, constant per-sensor sensor_from_rig poses), the per-image poses written to the exported images.txt do not match image.CamFromWorld() of the in-memory reconstruction. frames.txt + rigs.txt, however, are self-consistent: composing cam_from_world = cam_from_rig ∘ rig_from_world from them reproduces image.CamFromWorld() exactly (0.0000° for all images and all sensors).

In other words, after a rig-constrained reconstruction the model in memory is perfectly rigid (per-timestamp baseline norms between all sensors are constant, as expected for a physical rig), but the exported TXT carries per-image poses that violate this rigidity (baseline norms varying by ~±50% over time; per-image rotation errors up to 63° relative to the composition of the exported frames.txt/rigs.txt).

This makes any downstream consumer of the standard TXT format (undistortion, training pipelines, further SfM tools) see non-rigid cameras even though the rig-constrained solution itself is rigid.

Steps to reproduce

  1. Build a rig dataset per doc/rigs.html (two-pass workflow, unknown sensor poses):
    • Pass 1: plain colmap mapper without rigs (per-camera trivial rigs), partial model is fine.
    • colmap rig_configurator --database_path db --input_path <pass1 model> --rig_config_path rig.json (JSON omits cam_from_rig_* so poses are averaged from the pass-1 model; 448 multi-sensor frames written).
    • Pass 2: colmap mapper --Mapper.ba_refine_sensor_from_rig 0 (also reproduced with refinement enabled and with bundle_adjuster --BundleAdjustment.refine_sensor_from_rig 1).
  2. Load the final model and compare, for every image, image.CamFromWorld() against the exported images.txt:
python
import pycolmap, numpy as np

rec = pycolmap.Reconstruction("sparse/0")   # final rig model (binary)
Rig = rec.rigs[next(iter(rec.rigs))]

def R_of(r3d):
    return np.asarray(r3d.rotation.matrix())

def center(r3d):
    R = R_of(r3d); t = np.asarray(r3d.translation)
    return -R.T @ t

max_ang, n_mismatch, n = 0.0, 0, 0
for img_id, img in rec.images.items():
    cfw = img.cam_from_world()
    R_i = R_of(cfw)
    c_i = center(cfw)

    frame = rec.frames[img.frame_id()]
    R_f, t_f = (lambda r: (np.asarray(r.rotation.matrix()), np.asarray(r.translation)))(frame.rig_from_world())
    cam_id = img.camera_id
    if cam_id != Rig.ref_sensor_id.id:
        R_s, t_s = (lambda r: (np.asarray(r.rotation.matrix()), np.asarray(r.translation)))(Rig.sensor_from_rig(pycolmap.sensor_t(pycolmap.SensorType.CAMERA, cam_id)))
        R_c, t_c = R_s @ R_f, R_s @ t_f + t_s
    else:
        R_c, t_c = R_f, t_f

    # images.txt as exported by model_converter / write_text
    row = txt_rows[img.name()]           # standard COLMAP TXT parsing
    R_t = qvec_to_R(row["qvec"]); t_t = row["tvec"]
    c_t = -R_t.T @ t_t

    ang = np.degrees(np.arccos(np.clip((np.trace(R_c @ R_t.T) - 1) / 2, -1, 1)))
    dt = np.linalg.norm(c_t - c_i)      # camera-center mismatch
    n += 1
    if ang > 0.01:
        n_mismatch += 1
        max_ang = max(max_ang, ang)
print(f"{n_mismatch}/{n} images mismatch, max rotation error {max_ang:.2f} deg")

Actual result

  • images=1705, frames=434, rigs=1 (4 sensors, constant sensor_from_rig for the 3 non-reference sensors).
  • Composing frames.txt + rigs.txt reproduces image.CamFromWorld() exactly: max rotation error 0.0000°.
  • 1166 / 1705 exported images.txt entries do not match image.CamFromWorld(): rotation error up to 63°, camera-center distance up to 0.59 in model units.
  • Present right after mapper, and unchanged after running bundle_adjuster (refine_sensor_from_rig 0 or 1).
  • The exported frames.txt/rigs.txt are correct and rigid; only images.txt is inconsistent with them.

Expected result

images.txt should contain exactly image.CamFromWorld() (i.e., sensor_from_rig ∘ rig_from_world), so that the standard TXT round-trip preserves the rigid rig solution.

Impact

Any consumer of the exported TXT receives cameras that are inconsistent with the rig-constrained solution — in our case the poses look like an unconstrained per-image solution, which reintroduces the cross-camera inconsistency that the rig constraint was supposed to remove.

Notes

  • Verified with the official 4.2.0 Windows CUDA binary and the pycolmap 4.2.0 wheel (both model_converter TXT export and pycolmap write_text show the mismatch; frames.txt/rigs.txt are correct in both).
  • The rig dataset follows doc/rigs.html exactly (foldered images with identical basenames per frame, single_camera_per_folder 1, rig_configurator before sequential matching, two-pass pose derivation).
  • Happy to share the database/models for debugging (the raw images are from a proprietary scan but can be excluded — the ETH3D terrains rig example likely reproduces this as well).