Forum

Author Topic: [2.3.1] doc.save() to .psz silently drops sensor.axes  (Read 443 times)

Frédéric Devernay

  • Newbie
  • *
  • Posts: 9
    • View Profile
[2.3.1] doc.save() to .psz silently drops sensor.axes
« on: August 20, 2026, 06:21:24 PM »
Environment
  • Metashape Professional 2.3.1 (build 22580), standalone Python module
  • macOS 26 / arm64, wheel Metashape-2.3.1-cp33-abi3-macosx_10_13_x86_64
  • sensor.axes is new in 2.3, so this does not affect 2.0-2.2

Summary

Sensor.axes -- the sensor's local camera axis convention -- is serialized correctly by two of the three writers, and dropped by the third.

pathwrites axes?
doc.save("proj.psx")yes -- in proj.files/0/chunk.zip
chunk.exportCameras(..., CamerasFormatXML)yes -- in cameras.xml
doc.save("proj.psz")no
reading a .psz that contains <axes>yes, honoured

So the format supports the field, the value is representable, the .psz reader accepts it, and two sibling writers already produce it. Only the .psz writer omits it, which makes a save to .psz silently lossy: set a non-default axes, save, reopen, and the sensor has reverted to Aerial with nothing logged. Because the absence of the element means Aerial, a reader cannot tell "aerial" from "this writer lost it".

Minimal reproduction

Code: [Select]
import pathlib
import Metashape

tmp = pathlib.Path.home() / "axes_bug"
tmp.mkdir(exist_ok=True)

for ext in (".psx", ".psz"):
    doc = Metashape.Document()
    chunk = doc.addChunk()
    sensor = chunk.addSensor()
    sensor.label = "terrestrial"
    sensor.width, sensor.height = 64, 48
    sensor.axes = Metashape.Sensor.Axes.Terrestrial

    path = tmp / f"proj{ext}"
    doc.save(str(path))

    reloaded = Metashape.Document()
    reloaded.open(str(path), read_only=True)
    print(ext, "->", reloaded.chunk.sensors[0].axes)

Observed on 2.3.1:

Code: [Select]
.psx -> Sensor.Axes.Terrestrial
.psz -> Sensor.Axes.Aerial          # lost

No member of the .psz contains the string "axes", while proj.files/0/chunk.zip in the .psx contains <axes>YforwardZup</axes>.

The reader accepts what the writer omits

Patching the element into a natively written .psz -- adding <axes>YforwardZup</axes> inside a <sensor>, after <resolution> -- makes the value load correctly:

Code: [Select]
sensor without the element -> Sensor.Axes.Aerial
sensor with    the element -> Sensor.Axes.Terrestrial

This is therefore a writer omission rather than a format limitation.

The other two writers, for comparison

exportCameras with CamerasFormatXML, on a chunk whose sensor is Terrestrial:

Code: [Select]
<sensor id="0" label="unknown" type="frame">
  <resolution width="64" height="48"/>
  <axes>YforwardZup</axes>
  <property name="layer_index" value="0"/>
  ...
</sensor>

doc.save() to .psx, in proj.files/0/chunk.zip:

Code: [Select]
<sensor id="1" label="terr" type="frame">
  <resolution width="64" height="48"/>
  <axes>YforwardZup</axes>
  <property name="layer_index" value="0"/>
</sensor>

Identical element, identical position, identical token. The .psz written from the same in-memory document contains no axes at all.

Two related observations

1. The reader is compositional and reaches values the enum does not name. The vocabulary is {X,Y,Z}{forward,backward} followed by {X,Y,Z}{up,down}. All 36 tokens are accepted and decode to 34 distinct integers, of which only two are named in Sensor.Axes: 7 is Terrestrial (YforwardZup) and 11 is Aerial (ZbackwardYup). A project containing, say, ZforwardYup yields sensor.axes == 32, which prints as "32". That may well be intentional, but it means Python code cannot enumerate the possible values from the enum, and any code mapping by enum name silently loses the unnamed ones.

The forward direction is recoverable from the integer: for all 36 tokens, value mod 6 == forward_axis + 3*(1 if backward else 0), with X=0, Y=1, Z=2. Hence Aerial 11 mod 6 = 5 (Z backward) and Terrestrial 7 mod 6 = 1 (Y forward). The quotient encodes the up direction but not as any obvious function of it.

Twelve of the 36 tokens are geometrically degenerate, with forward parallel to up, and two of those alias onto valid combinations: ZbackwardZup decodes to the same 17 as ZbackwardXdown, and ZbackwardZdown to the same 29 as ZbackwardXup. Rejecting a degenerate combination would be more useful than aliasing it.

2. An unparseable value falls back to Aerial silently. Matching is exact and case-sensitive with no trimming, so "yforwardzup", "YFORWARDZUP", " YforwardZup ", "Yforward" and "7" all load as Aerial without a warning. A malformed convention is indistinguishable from an absent one, which makes a typo in a hand-written or third-party project impossible to detect.

Requested fix

Write &lt;axes&gt; from the .psz writer, as the .psx writer and exportCameras already do. Optionally: name the remaining conventions in Sensor.Axes so they are reachable from Python, and reject an unparseable &lt;axes&gt; value instead of silently substituting Aerial.

Why it matters

sensor.axes is the principled way to make yaw/pitch/roll references meaningful for terrestrial capture, instead of switching the reference angles to omega/phi/kappa to compensate for an aerial-oriented local frame. As it stands, a project relying on it cannot be round-tripped through the .psz format, so the workaround remains necessary.

Alexey Pasumansky

  • Agisoft Technical Support
  • Hero Member
  • *****
  • Posts: 15726
    • View Profile
Re: [2.3.1] doc.save() to .psz silently drops sensor.axes
« Reply #1 on: August 26, 2026, 03:37:13 PM »
Hello Frédéric,

Thank you for reporting the problem.

Fix will be included in the next version update.
Best regards,
Alexey Pasumansky,
Agisoft LLC