Source code for weitsicht.metadata.eor_from_meta
# -----------------------------------------------------------------------
# Copyright 2026 Martin Wieser
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------
# We follow the specifications from here, but there is nothing standardized at all
# https://support.pix4d.com/hc/en-us/articles/360016450032
import logging
import numpy as np
from numpy import cos, sin
from pyproj import CRS
from pyproj.crs.crs import CompoundCRS
from weitsicht.exceptions import CoordinateTransformationError
from weitsicht.metadata.metadata_results import (
EORFromMetaResult,
EORFromMetaResultSuccess,
MetadataIssue,
)
from weitsicht.metadata.tag_systems.tag_base import MetaTagAll
from weitsicht.transform.rotation import Rotation
from weitsicht.transform.utm_converter import point_convert_utm_wgs84_egm2008
from weitsicht.utils import ResultFailure
logger = logging.getLogger(__name__)
aircraft_notation_to_front_notation = np.array([[1, 0, 0], [0, -1, 0], [0, 0, -1]])
swap_ned_to_enu_coo_system = np.array([[0, 1, 0], [1, 0, 0], [0, 0, -1]])
swap_body_cam = np.array([[0, 1, 0], [-1, 0, 0], [0, 0, 1]])
swap_body_cam_gimbal = np.array([[0, 0, -1], [-1, 0, 0], [0, 1, 0]])
[docs]
def eor_from_meta(
tags: MetaTagAll,
crs: CRS | None = None,
vertical_ref: str = "ellipsoidal",
height_rel: float = 0.0,
) -> EORFromMetaResult:
"""Extract exterior orientation (position + rotation + CRS) from metadata tags.
The returned position is expressed in a local projected coordinate system derived from the
input CRS (typically WGS84) by converting to WGS84-UTM with EGM2008 heights.
If XMP tags ``HorizCS``/``VertCS`` are missing, defaults to WGS84 (EPSG:4979) with ellipsoidal heights.
:param tags: Grouped metadata values (e.g. from ``MetaTagsBase.get_all()``).
:type tags: MetaTagAll
:param crs: Optional override CRS for interpreting the position tags, defaults to ``None``.
:type crs: CRS | None
:param vertical_ref: Vertical reference mode: ``ellipsoidal``, ``orthometric``, or ``relative``.
``relative`` uses ``RelativeAltitude`` + ``height_rel``.
:type vertical_ref: str
:param height_rel: Reference height (meters) for ``vertical_ref='relative'``, defaults to ``0.0``.
:type height_rel: float
:return: Successful EOR result or a failure result.
:rtype: EORFromMetaResult
"""
crs_source: CRS | None = crs
position = np.array([0, 0, 0])
lon = tags.gps.gps_longitude
lat = tags.gps.gps_latitude
alt = tags.gps.gps_altitude
lon_ref = tags.gps.gps_longitude_ref or "E"
lat_ref = tags.gps.gps_latitude_ref or "N"
if lon is not None and lat is not None and alt is not None:
x_exif = lon
if lon_ref == "W":
x_exif = -x_exif
y_exif = lat
if lat_ref == "S":
y_exif = -y_exif
z_exif = alt
else:
return ResultFailure(ok=False, error="Standard GPS tags are missing", issues={MetadataIssue.MISSING_GPS})
#
rel_z_exif = None
if vertical_ref == "relative":
rel_z_exif = tags.z_alternatives.rel_altitude
if rel_z_exif is not None:
z_exif = height_rel + float(rel_z_exif)
# Determine CRS for interpreting the input coordinates if not overridden by the user.
if crs_source is None:
if vertical_ref == "orthometric":
crs_hor_exif = 4326
crs_vert_exif = 3855
else:
crs_hor_exif = tags.crs.horiz_cs or 4979
crs_vert_exif = tags.crs.vert_cs or "ellipsoidal"
if tags.crs.horiz_cs is not None:
crs_hor_exif = tags.crs.horiz_cs
crs_vert_exif = tags.crs.vert_cs or "ellipsoidal"
# This is now an override if relative height is specified:
# Then we will
if rel_z_exif is not None:
crs_hor_exif = 4326
crs_vert_exif = 3855
if crs_vert_exif == "ellipsoidal":
crs_source = CRS(crs_hor_exif).to_3d()
else:
crs_source = CompoundCRS(
str(crs_hor_exif) + "+" + str(crs_vert_exif),
[crs_hor_exif, crs_vert_exif],
)
assert crs_source is not None
try:
x, y, z, crs_result = point_convert_utm_wgs84_egm2008(crs_source, x_exif, y_exif, z_exif)
except (ValueError, CoordinateTransformationError) as err:
return ResultFailure(
ok=False, error=str(err), issues={MetadataIssue.TRANSFORMATION_FAILED, MetadataIssue.EOR_FAILED}
)
position = np.array([x, y, z])
# Orientation
xmp_cam_pitch = tags.orientation.xmp_camera_pitch
xmp_cam_roll = tags.orientation.xmp_camera_roll
xmp_cam_yaw = tags.orientation.xmp_camera_yaw
maker_notes_cam_pitch = tags.orientation.maker_notes_camera_pitch
maker_notes_cam_roll = tags.orientation.maker_notes_camera_roll
maker_notes_cam_yaw = tags.orientation.maker_notes_camera_yaw
xmp_pitch_xyz = tags.orientation.xmp_pitch
xmp_roll_xyz = tags.orientation.xmp_roll
xmp_yaw_xyz = tags.orientation.xmp_yaw
maker_pitch_xyz = tags.orientation.maker_notes_pitch
maker_roll_xyz = tags.orientation.maker_notes_roll
maker_yaw_xyz = tags.orientation.maker_notes_yaw
xmp_gimbal_roll = tags.orientation.xmp_gimbal_roll_deg
xmp_gimbal_yaw = tags.orientation.xmp_gimbal_yaw_deg
xmp_gimbal_pitch = tags.orientation.xmp_gimbal_pitch_deg
maker_notes_gimbal_roll = tags.orientation.maker_notes_gimbal_roll_deg
maker_notes_gimbal_yaw = tags.orientation.maker_notes_gimbal_yaw_deg
maker_notes_gimbal_pitch = tags.orientation.maker_notes_gimbal_pitch_deg
angle_in_direction_of_view = False
pitch = None
if xmp_cam_roll is not None and xmp_cam_yaw is not None and xmp_cam_pitch is not None:
angle_in_direction_of_view = True
roll = _deg(xmp_cam_roll)
yaw = _deg(xmp_cam_yaw)
pitch = _deg(xmp_cam_pitch)
elif maker_notes_cam_roll is not None and maker_notes_cam_yaw is not None and maker_notes_cam_pitch is not None:
angle_in_direction_of_view = True
roll = _deg(maker_notes_cam_roll)
yaw = _deg(maker_notes_cam_yaw)
pitch = _deg(maker_notes_cam_pitch)
elif xmp_roll_xyz is not None and xmp_yaw_xyz is not None and xmp_pitch_xyz is not None:
roll = _deg(xmp_roll_xyz)
yaw = _deg(xmp_yaw_xyz)
pitch = _deg(xmp_pitch_xyz)
elif maker_roll_xyz is not None and maker_yaw_xyz is not None and maker_pitch_xyz is not None:
roll = _deg(maker_roll_xyz)
yaw = _deg(maker_yaw_xyz)
pitch = _deg(maker_pitch_xyz)
elif xmp_gimbal_roll is not None and xmp_gimbal_yaw is not None and xmp_gimbal_pitch is not None:
angle_in_direction_of_view = True
roll = _deg(xmp_gimbal_roll)
yaw = _deg(xmp_gimbal_yaw)
pitch = _deg(xmp_gimbal_pitch)
elif (
maker_notes_gimbal_roll is not None
and maker_notes_gimbal_yaw is not None
and maker_notes_gimbal_pitch is not None
):
angle_in_direction_of_view = True
roll = _deg(maker_notes_gimbal_roll)
yaw = _deg(maker_notes_gimbal_yaw)
pitch = _deg(maker_notes_gimbal_pitch)
else:
return ResultFailure(
ok=False,
error="Orientation angles are not in meta-data",
issues={MetadataIssue.MISSING_ORIENTATION, MetadataIssue.EOR_FAILED},
)
if pitch is None:
return ResultFailure(
ok=False,
error="Orientation angles are not in meta-data",
issues={MetadataIssue.MISSING_ORIENTATION, MetadataIssue.EOR_FAILED},
)
# Rotation of IMAGE still in Body System
rot_sys = np.array(
[
[
cos(pitch) * cos(yaw),
sin(roll) * sin(pitch) * cos(yaw) - cos(roll) * sin(yaw),
cos(roll) * sin(pitch) * cos(yaw) + sin(roll) * sin(yaw),
],
[
cos(pitch) * sin(yaw),
sin(roll) * sin(pitch) * sin(yaw) + cos(roll) * cos(yaw),
cos(roll) * sin(pitch) * sin(yaw) - sin(roll) * cos(yaw),
],
[-sin(pitch), sin(roll) * cos(pitch), cos(roll) * cos(pitch)],
]
)
# Bring rotation into the cameras coordinate system. X left, Y top, Z backwards of viewing direction
rot_enu_body = (swap_ned_to_enu_coo_system @ rot_sys) @ aircraft_notation_to_front_notation
if angle_in_direction_of_view:
rot_enu_cam = rot_enu_body @ swap_body_cam_gimbal
else:
rot_enu_cam = rot_enu_body @ swap_body_cam
orientation = Rotation(rot_enu_cam)
return EORFromMetaResultSuccess(ok=True, position=position, orientation=orientation, crs=crs_result)