Skip to content

🐛 convertCameraPointToFramePoint ignores normalising extents on orientation swap (iOS) — square region maps to a 3.16:1 strip #4114

Description

@florianpreusner

Prerequisites

  • I have read and followed every applicable step in the Nitro Modules Troubleshooting guide.
  • I have read the VisionCamera Troubleshooting guide.
  • I have searched existing issues and found nothing matching (no hits for convertCameraPointToFramePoint).
  • I am on the latest version, or have a specific reason I cannot upgrade — see "VisionCamera version" below.

Reproduction

#4113 — adds one failing Harness test, maps a square Frame region onto a square View region, to apps/simple-camera/__tests__/visioncamera.coordinates.harness.tsx.

Steps to reproduce

  1. Check out the branch in test: Add failing Harness test — square Frame region maps to a 3.16:1 strip in View space on iOS #4113.
  2. Run the coordinates harness on an iOS device: bun run test:harness:ios -- --testPathPatterns=coordinates.
  3. Watch maps a square Frame region onto a square View region.
  4. Run the same file on an Android device — it passes there.

What did you expect to happen?

Composing the two public coordinate conversions should preserve aspect. A square region in Frame space should map to a square region in View space:

PreviewView.convertViewPointToCameraPoint()  ->  Frame.convertCameraPointToFramePoint()

Every transform in between — sensor scale, orientation counter-rotation, the preview's aspect-preserving crop — preserves aspect. Camera space itself is allowed to be anisotropic (iOS normalises per-axis to [0, 1]), but converting out of it should undo the same per-axis scaling it applied.

What actually happened?

On iOS the square comes back as a wide, short strip. Measured on iPhone 15 Pro Max, frame 3840×2160, orientation: "left", using a square guide cell of identical ±19.7 dp in both view axes:

sensor Δx 0.0562  ->  frame Δy 121.4 px
sensor Δy 0.0999  ->  frame Δx 383.6 px

383.6 × 121.4 px — a ratio of 3.159, where a correct mapping measures 1.000. Pixel 8 (1280×720) composes correctly at 1.000.

convertViewPointToCameraPoint looks correct on its own: for the square patch it returns Δx/Δy = 0.5625 = exactly 9/16, consistent with per-axis normalisation over a 16:9 image.

Suspected cause. ios/Utils/FrameCoordinateSystemConverter.getFrameToCameraMatrix rotates within [0, 1]² in step 1, then normalises in step 3 with matrix.scaledBy(x: 1 / width, y: 1 / height). scaledBy post-concatenates, so the point is normalised by the buffer's own extents before the rotation swaps the axes — and the extents never swap with it. The numbers match that reading exactly: 0.0562 × 2160 = 121.4, 0.0999 × 3840 = 383.6. Predicted anisotropy for 16:9 at orientation: left is (16/9)² = 3.1605 against the measured 3.159.

The existing tests can't catch it: the Frame→Camera→Frame round-trip is self-inverse (getCameraToFrameMatrix is getFrameToCameraMatrix().inverted()), and the end-to-end center test uses the frame center, which maps to camera (0.5, 0.5) under both a correct and an anisotropic matrix. Only an off-center region exposes it. Full analysis in the PR.

Affected platforms

iOS (device)

Device(s) affected

iPhone 15 Pro Max (iOS 26.5.2) — reproduces. Pixel 8 — does not reproduce (exact Android build not recorded at measurement time).

VisionCamera version

5.0.11. Not on the latest (5.2.0) because 5.2.0 is nitrogen-0.36.3-generated and our app is still on react-native-nitro-modules 0.35.x — that upgrade is scheduled separately.

This is very unlikely to matter here: ios/Utils/FrameCoordinateSystemConverter.swift is byte-identical across v5.0.11, v5.1.0, v5.1.1 and v5.2.0 (md5 7e406d9d979e0490f06ca05f7e52ea70, 74 lines each), and no 5.x release note touches it. Happy to re-measure on 5.2.0 if you'd like it confirmed on the current release.

React Native version

0.86.0 at measurement time (since upgraded to 0.86.2).

React Native architecture

New Architecture (Fabric / bridgeless)

Context

Found while mapping a Rubik's cube scan guide onto sampled frames in a shipping app. Our workaround validates the composed mapping with an isotropy check and falls back to deriving the geometry from the frame dimensions when it fails — no platform branch, it's measured at runtime. Android accepts the library transform; iOS rejects it at 3.159 and falls back. Glad to test a fix on device.

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