2.3.1 Python API: rig slave-sensor reference.rotation convention inverted (transposed) vs 2.2Environment- Metashape Professional 2.3.1 (build 22580), standalone Python module
- macOS (also reproduced on Linux); wheel metashape-2.3.1-*-abi3
- Reproduced on CPython 3.11, 3.12 and 3.13 (not Python-version dependent)
- Compared against Metashape 2.2.3 (build 21752)
SummaryA camera rig's
slave sensor stores its orientation relative to the master in
sensor.reference.rotation, as fixed
OPK (omega/phi/kappa) Euler angles. Between 2.2 and 2.3 the
direction of that rotation was inverted:
- 2.2.3: sensor.reference.rotation is the OPK of the master->slave rotation R — you set it with mat2opk(R).
- 2.3.1: the same field is interpreted as the OPK of the inverse rotation R^T — you must set mat2opk(R.transpose()) to express the same physical slave offset.
So code that sets a slave rotation reference the 2.2 way places the slave in the
inverse orientation on 2.3: you intend
R but 2.3 applies
R^T, so the slave is wrong by
R^2 —
about twice the offset angle (this is exactly why the 20 deg test offset below drifts ~40 deg). The error is silent. The angle-conversion utilities (
Metashape.Utils.mat2opk,
opk2mat, ...) are unchanged; only how
optimizeCameras interprets the stored slave reference flipped.
How this was measured. optimizeCameras is the only operation that consumes a slave sensor's rotation reference, so the test is behavioral: build a rig with a known slave offset, solve it to get the rotation matrix
solved, then set the reference to an OPK encoding of a candidate matrix (with tight accuracy) and re-optimize. If the encoding matches Metashape's interpretation the prior agrees with the solution and the slave does
not move (0 deg); otherwise the high-accuracy prior drags it
away from
solved. Feeding back
solved itself vs. its transpose isolates the direction:
reference set to mat2opk(solved) reference set to mat2opk(solved^T)
Metashape 2.2.3: 0.000 deg (slave stays put) 39.998 deg (dragged away)
Metashape 2.3.1: 39.998 deg (dragged away) 0.000 deg (slave stays put)
=> 2.2 wants
mat2opk(solved); 2.3 wants
mat2opk(solved^T). (
chunk.euler_angles is
YPR here, yet the reference is still read as OPK on both versions — it is
not being reinterpreted as YPR; only the rotation
direction flipped.)
Minimal reproductionSelf-contained (Metashape + Pillow); builds a 10-station x 2-face master/slave rig with blank tiles (pixels are never read; camera poses and marker observations are set analytically). Core:
import Metashape
# ... build_rig(): 2 sensors, sensor[0].makeMaster(); slave = sensor[1];
# known offset R_TRUE = opk2mat([0, 20, 0]); exact marker projections ...
ch.euler_angles = Metashape.EulerAnglesYPR
ch.optimizeCameras(fit_f=False, fit_cx=False, fit_cy=False, adaptive_fitting=False)
solved = slave.rotation # the solved slave-offset rotation
def move_after(angles): # geodesic drift of slave from `solved`
slave.rotation = solved
slave.reference.rotation = angles
slave.reference.rotation_accuracy = Metashape.Vector([1e-4, 1e-4, 1e-4])
slave.reference.rotation_enabled = slave.reference.enabled = True
slave.fixed_rotation = False
ch.optimizeCameras(fit_f=False, fit_cx=False, fit_cy=False, adaptive_fitting=False)
return geodesic_angle(slave.rotation, solved)
print(Metashape.version)
print("mat2opk(solved) :", move_after(Metashape.Utils.mat2opk(solved)))
print("mat2opk(solved.t()):", move_after(Metashape.Utils.mat2opk(solved.t())))
Output:
# Metashape 2.2.3.21752
mat2opk(solved) : 0.000 deg
mat2opk(solved.t()): 39.998 deg
# Metashape 2.3.1.22580
mat2opk(solved) : 39.998 deg
mat2opk(solved.t()): 0.000 deg
Sweeping all four Euler encodings of
solved on 2.3.1 confirms
no non-transposed standard encoding round-trips (all should be ~0 deg if the 2.2 convention held):
mat2opk(solved) -> 39.998 mat2ypr(solved) -> 28.211 mat2pok(solved) -> 28.210 mat2ank(solved) -> 123.944
The encoder did not change — only the interpretationMetashape.Utils.mat2opk /
mat2ypr return
identical values on 2.2.3 and 2.3.1 for the same matrix (verified numerically), and
Metashape.Utils.opk2mat is unchanged. The regression is purely in how
optimizeCameras interprets a slave sensor's
reference.rotation (it now expects the transposed rotation), not in the angle-conversion utilities.
Expected vs. actual- Expected (as in 2.2.3, and matching the GUI/manual, which show Omega/Phi/Kappa for rig slave offsets): sensor.reference.rotation = mat2opk(R) sets the slave-offset rotation to R; feeding back mat2opk(solved) leaves the solved slave untouched.
- Actual (2.3.1): feeding back mat2opk(solved) rotates the slave ~40 deg away from its own solution; only mat2opk(solved.transpose()) is self-consistent.
Why this reads as a regression, not a documented change- It is undocumented: neither the GUI changelog nor the Python API Change Log for 2.3.0 / 2.3.1 mentions a change to the rig slave-sensor rotation-reference convention. (The 2.3 reference-CSV Euler additions — rotation_angles/load_rotation on Chunk.importReference/exportReference — concern camera/marker reference CSVs, not the rig slave-sensor offset.)
- The community/GUI convention for slave offsets is OPK of the master->slave rotation (e.g. forum topic 15021), i.e. the 2.2 behavior.
- Any code that sets a rig slave sensor's rotation reference assuming the 2.2 convention (from a stored calibration or a hardcoded offset) is silently mis-oriented by R^2 on 2.3.1 — the slave lands at R^T instead of the intended R, i.e. off by about twice the offset angle.
ImpactCode paths that set a rig slave sensor's
reference.rotation directly (cubemap / multiplane rig calibration importers) produce a mis-oriented rig on 2.3.1 while reporting success, unless they transpose the rotation before OPK-encoding.
Workaround (version-gated)Because the encoder is unchanged and only the interpreted direction flipped, a deterministic fix is to transpose the rotation before encoding on >= 2.3.0:
R_for_ref = solved if Metashape.version < "2.3.0" else solved.t()
slave.reference.rotation = Metashape.Utils.mat2opk(R_for_ref)
This
must be gated on
Metashape.version: at the point of setting the reference there is no cheap local signal to distinguish the conventions without a solve-and-check probe, so the 2.3.0 boundary is the pragmatic gate. Verify the result against held-out check points, since a wrong convention still "succeeds".
Suggested fixRestore the 2.2 slave-offset rotation-reference convention (OPK of the master->slave rotation, non-transposed), or document the change explicitly in the Python API Change Log with the exact frame/direction so callers can adapt deterministically.