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
SummarySensor.axes -- the sensor's local camera axis convention -- is serialized correctly by two of the three writers, and dropped by the third.
| path | writes 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 reproductionimport 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:
.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 omitsPatching the element into a natively written .psz -- adding <axes>YforwardZup</axes> inside a <sensor>, after <resolution> -- makes the value load correctly:
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 comparisonexportCameras with CamerasFormatXML, on a chunk whose sensor is Terrestrial:
<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:
<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 observations1. 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 fixWrite <axes> 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 <axes> value instead of silently substituting Aerial.
Why it matterssensor.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.