Skip to content

iOS north-referenced frame conversion applied on wrong side → azimuth 90° off when device is upright #100

Description

@henningof

Summary

On iOS, for the north-referenced frames (magneticNorth / trueNorth), the Core Motion → ENU world-frame conversion is applied on the wrong side of the rotation. It is applied as a device-axis remap (right-multiplication) when it needs to be a world-frame change of basis (left-multiplication). As a result the azimuth/heading is correct only when the device is flat, and is exactly 90° off when the device is held upright. Android is unaffected.

This makes the fix from #95 correct on a table but wrong in the common upright/portrait "point the camera at something" pose.

Where

lib/src/rotation_sensor_method_channel.dart (around lines 51–56):

// Core Motion uses a world frame with X = north and Z = up. For iOS
// north-referenced frames, convert it here to Y = north and Z = up to
// match Android and this package's convention.
.magneticNorth || .trueNorth =>
    defaultTargetPlatform == TargetPlatform.iOS
        ? orientationEvent.remapCoordinateSystem(Axis3.Y, -Axis3.X)
        : orientationEvent,

remapCoordinateSystem (in lib/src/orientation_event.dart, ~line 105) right-multiplies the quaternion:

final transformMatrix = Matrix3.columns(newX, newY, newZ);
return OrientationEvent._(
  quaternion.multiply(transformMatrix.toQuaternion()), // q ⊗ q_T  (right-multiply)
  ...
);

and it is documented as remapping the device coordinate system — which is what it's correctly used for in DisplayCoordinateSystem.apply (portrait-down / landscape). It is the wrong operation for a world-frame conversion.

Why it's wrong

The rotation matrix maps device → world (columns are the device axes expressed in world coordinates). CMAttitude follows this same device→reference convention as Android's rotation vector — e.g. CardboardSDK-iOS names GLKMatrix4Transpose(attitude.rotationMatrix) as deviceFromInertialReferenceFrame, so the untransposed rotationMatrix is reference-from-device… i.e. it takes a device vector to the reference/world frame.

The two world frames differ:

  • Core Motion xMagneticNorthZVertical: X = north, Z = up ⇒ Y = Z×X = west.
  • This package (Android/ENU): X = east, Y = north, Z = up.

Converting world coordinates from the iOS frame to ENU is a fixed change of basis C = Rz(90°):

C = [ 0 -1  0 ]
    [ 1  0  0 ]
    [ 0  0  1 ]

For a device vector d, its ENU world coords are C · (R_ios · d) = (C · R_ios) · d, so the corrected matrix is:

R_enu = C · R_ios          (LEFT-multiply)

or, as quaternions:

q_enu = q_C ⊗ q_ios        (NOT  q_ios ⊗ q_C)

The package currently computes R_ios · T / q_ios ⊗ q_T. Numerically T == C == Rz(90°), so the two orderings agree only when R_ios commutes with a rotation about the vertical axis — i.e. when the device lies flat (screen up). That's why flat-on-table azimuth tests pass but the upright pose is 90° off.

Reproduce

Hold the phone upright (portrait), back camera pointing magnetic north.

  • Expected: the lens direction is device −Z, so the rotation matrix's third column should be ≈ (0, 1, 0) (north) in ENU.
  • Actual on iOS: the third column reads ≈ (1, 0, 0) (east) — a 90° clockwise error. Android reports it correctly.

(Flat on the table, top edge pointing any direction, reads correctly on both platforms — which is why this is easy to miss.)

Suggested fix

For the magneticNorth / trueNorth frames on iOS, apply the world-frame change of basis by left-multiplying instead of using remapCoordinateSystem, e.g.:

// transform = Rz(90°) as a quaternion; left-multiply to change the world frame.
quaternion: transformQuaternion.multiply(event.quaternion), // q_C ⊗ q_ios

(and correspondingly C.multiply(coordinateSystem) for the stored coordinateSystem).

Notes / workaround

Found while building an in-app camera compass. As a temporary workaround we conjugate every event on iOS — R' = C · R · Cᵀ — which is exactly equivalent to the left-multiply fix once the package's subsequent display-orientation remap (a device-Z rotation, so it commutes with C) is accounted for, and it's exact in every pose and display orientation. We've pinned the dependency to 0.3.0 so that when this is fixed upstream our compensation doesn't double-correct.

Thanks for the package!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions