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

Author: florianpreusnerCreated Jul 30, 2026Updated Aug 30, 2026

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

https://github.com/mrousavy/react-native-vision-camera/pull/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 #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.

Source: mrousavy/react-native-vision-camera