react-native-image-stitcher 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,6 +36,7 @@ import org.opencv.features2d.ORB
36
36
  import org.opencv.imgcodecs.Imgcodecs
37
37
  import org.opencv.imgproc.Imgproc
38
38
  import java.io.File
39
+ import io.imagestitcher.rn.ar.YuvImageConverter
39
40
  import java.util.concurrent.atomic.AtomicBoolean
40
41
 
41
42
  /**
@@ -76,6 +77,13 @@ class IncrementalStitcher(
76
77
  private val reactContext: ReactApplicationContext,
77
78
  ) : ReactContextBaseJavaModule(reactContext) {
78
79
 
80
+ // F8.4 note: the static singleton accessor for cross-thread
81
+ // lookup (used by `CvFlowGateFrameProcessor` running on vision-
82
+ // camera's producer thread) is the existing `bridgeInstance`
83
+ // companion field below — same pattern that `RNSARCameraView`
84
+ // uses to call back into the bridge. No new companion object
85
+ // needed.
86
+
79
87
  override fun getName(): String = "IncrementalStitcher"
80
88
 
81
89
  /// Required by RCTEventEmitter contract. No-op on Android because
@@ -217,6 +225,26 @@ class IncrementalStitcher(
217
225
  private var consumeFrameCounter: Long = 0L
218
226
 
219
227
  private val isRunning = AtomicBoolean(false)
228
+
229
+ /// F8.4 — gate for `consumeFrameFromPlugin` (the vision-camera
230
+ /// Frame Processor producer-thread entry point on Android).
231
+ /// TRUE only when the current capture was started with
232
+ /// `frameSourceMode == "frameProcessor"`. In other modes
233
+ /// (especially the legacy `"jsDriver"` path that feeds via
234
+ /// `processFrameAtPath`), the plugin would double-feed the
235
+ /// engine — bytes from the producer thread + JPEG paths from
236
+ /// the JS interval, racing on the same workScope serial
237
+ /// dispatcher — so we drop the producer-thread call.
238
+ ///
239
+ /// AtomicBoolean: producer thread reads lock-free, JS thread
240
+ /// (start/cancel/finalize) writes via `set()`/`compareAndSet()`.
241
+ /// Mirror of iOS' `frameProcessorIngestEnabled` ivar.
242
+ private val frameProcessorIngestEnabled = AtomicBoolean(false)
243
+
244
+ /// F8.6 perf-diagnostic — one-shot guard so the "live-engine
245
+ /// route" log fires exactly once per capture session. Reset
246
+ /// in start().
247
+ private val f8_6_routeLoggedThisCapture = AtomicBoolean(false)
220
248
  /// Critic #5 fix: serial dispatcher so concurrent
221
249
  /// processFrameAtPath() calls can't race on the engine's canvas.
222
250
  /// `limitedParallelism(1)` guarantees one-at-a-time execution
@@ -301,6 +329,18 @@ class IncrementalStitcher(
301
329
  }
302
330
  try {
303
331
  ensureOpenCv()
332
+ // F8.4 — frameSourceMode honoured on Android. Pre-F8.4,
333
+ // Android ignored this option (only iOS interpreted it).
334
+ // Now `"frameProcessor"` unlocks `consumeFrameFromPlugin`'s
335
+ // producer-thread ingest path; everything else (the
336
+ // implicit default + the legacy `"jsDriver"`) keeps the
337
+ // ingest path dormant so the existing `processFrameAtPath`
338
+ // / ARCore paths run unmodified.
339
+ val frameSourceMode = options.getString("frameSourceMode") ?: "jsDriver"
340
+ frameProcessorIngestEnabled.set(frameSourceMode == "frameProcessor")
341
+ // F8.6 perf-diagnostic — re-arm the route log for the
342
+ // new capture.
343
+ f8_6_routeLoggedThisCapture.set(false)
304
344
  val rotation = options.getIntOrDefault("frameRotationDegrees", 90)
305
345
  val composeW = options.getIntOrDefault("composeWidth", 960)
306
346
  val composeH = options.getIntOrDefault("composeHeight", 720)
@@ -548,6 +588,7 @@ class IncrementalStitcher(
548
588
  promise.resolve(map)
549
589
  } catch (t: Throwable) {
550
590
  isRunning.set(false)
591
+ frameProcessorIngestEnabled.set(false) // F8.4 — symmetric clear on error path
551
592
  promise.reject("incremental-start-failed", t.message, t)
552
593
  }
553
594
  }
@@ -935,6 +976,7 @@ class IncrementalStitcher(
935
976
  // bail at the re-check (see processFrameAtPath above).
936
977
  // Matches iOS V12.1 fix.
937
978
  isRunning.set(false)
979
+ frameProcessorIngestEnabled.set(false) // F8.4 — cut producer-thread ingest at finalize
938
980
 
939
981
  // V16 batch-keyframe finalize: snapshot the keyframe state
940
982
  // synchronously under the same "stop ingestion before
@@ -1133,6 +1175,7 @@ class IncrementalStitcher(
1133
1175
  // iOS V12.1 cancel path.
1134
1176
  arCameraViewRef?.setIncrementalIngestionActive(false)
1135
1177
  isRunning.set(false)
1178
+ frameProcessorIngestEnabled.set(false) // F8.4 — cut producer-thread ingest at cancel
1136
1179
  val hybrid = engine
1137
1180
  val firstwins = firstwinsEngine
1138
1181
  engine = null
@@ -1227,6 +1270,24 @@ class IncrementalStitcher(
1227
1270
  // can check `isBatchKeyframeMode` to elide the per-frame
1228
1271
  // JPEG encode for the batch path.
1229
1272
  legacyJpegPath: String? = null,
1273
+ // F8.6 — pixel-data path for live engines. When supplied
1274
+ // (and `batchKeyframeMode == false`), takes precedence over
1275
+ // `legacyJpegPath`: the live engine ingests via
1276
+ // `addFramePixelData` (NV21 → BGR Mat in-process) instead of
1277
+ // `addFrameAtPath` (JPEG decode round-trip). Saves ~30-50 ms
1278
+ // per accepted frame on a mid-tier device. Pass null to use
1279
+ // the legacy JPEG path.
1280
+ //
1281
+ // OWNERSHIP: the engine retains a reference to `nv21PixelData`
1282
+ // until `workScope`'s coroutine consumes it (~50 ms later).
1283
+ // Callers MUST treat the array as transferred — do not
1284
+ // mutate it or return it to a buffer pool after calling
1285
+ // this method. If a caller needs to recycle the buffer,
1286
+ // pass `.copyOf()` (currently no caller does — the F8.4
1287
+ // Frame Processor plugin allocates a fresh array per frame).
1288
+ nv21PixelData: ByteArray? = null,
1289
+ nv21PixelWidth: Int = 0,
1290
+ nv21PixelHeight: Int = 0,
1230
1291
  ) {
1231
1292
  // ── V16 batch-keyframe: AR-driven path ─────────────────────
1232
1293
  //
@@ -1404,46 +1465,280 @@ class IncrementalStitcher(
1404
1465
  // we only get here when batchKeyframeMode == false. Caller
1405
1466
  // (RNSARCameraView) was expected to supply legacyJpegPath in
1406
1467
  // that case — defensively drop the frame if it didn't.
1407
- val path = legacyJpegPath ?: run {
1468
+ // F8.6 — prefer the pixel-data path when the caller supplied
1469
+ // NV21 bytes (Frame Processor / refactored ARCore path),
1470
+ // otherwise fall back to legacyJpegPath (un-migrated ARCore
1471
+ // path). At least one of them must be present; drop the
1472
+ // frame defensively otherwise.
1473
+ val hasPixelData = nv21PixelData != null
1474
+ && nv21PixelWidth > 0
1475
+ && nv21PixelHeight > 0
1476
+ // F8.6 perf-diagnostic — log route choice once per capture
1477
+ // so logcat shows whether the new pixel-data path is
1478
+ // actually getting exercised. Throttled to first-hit per
1479
+ // capture (counter resets in start()/cancel()).
1480
+ if (!f8_6_routeLoggedThisCapture.getAndSet(true)) {
1481
+ android.util.Log.i(
1482
+ "F8.6-route",
1483
+ "ingestFromARCameraView live-engine route: "
1484
+ + if (hasPixelData) "pixel-data (F8.6, no JPEG round-trip)"
1485
+ else "jpeg-path (legacy, JPEG decode round-trip)",
1486
+ )
1487
+ }
1488
+ val path = if (hasPixelData) null else legacyJpegPath ?: run {
1408
1489
  android.util.Log.w(
1409
1490
  "IncrementalStitcher",
1410
1491
  "ingestFromARCameraView legacy: batchKeyframeMode=false " +
1411
- "but legacyJpegPath is null — dropping frame. " +
1412
- "Caller should have encoded a JPEG when " +
1492
+ "but both legacyJpegPath and nv21PixelData are null — " +
1493
+ "dropping frame. Caller should have encoded a JPEG " +
1494
+ "OR supplied NV21 pixel data when " +
1413
1495
  "isBatchKeyframeMode == false.",
1414
1496
  )
1415
1497
  return
1416
1498
  }
1417
1499
  workScope.launch {
1418
1500
  val state: WritableMap? = if (firstwins != null) {
1419
- val tele = firstwins.addFrameAtPath(
1420
- path = path,
1421
- qx = qx, qy = qy, qz = qz, qw = qw,
1422
- fx = fx, fy = fy, cx = cx, cy = cy,
1423
- imageWidth = imageWidth, imageHeight = imageHeight,
1424
- yaw = yaw, pitch = pitch,
1425
- fovHorizDegrees = fovHorizDegrees,
1426
- fovVertDegrees = fovVertDegrees,
1427
- trackingPoor = trackingPoor,
1428
- )
1501
+ val tele = if (hasPixelData) {
1502
+ firstwins.addFramePixelData(
1503
+ nv21 = nv21PixelData!!,
1504
+ nv21Width = nv21PixelWidth,
1505
+ nv21Height = nv21PixelHeight,
1506
+ qx = qx, qy = qy, qz = qz, qw = qw,
1507
+ fx = fx, fy = fy, cx = cx, cy = cy,
1508
+ imageWidth = imageWidth, imageHeight = imageHeight,
1509
+ yaw = yaw, pitch = pitch,
1510
+ fovHorizDegrees = fovHorizDegrees,
1511
+ fovVertDegrees = fovVertDegrees,
1512
+ trackingPoor = trackingPoor,
1513
+ )
1514
+ } else {
1515
+ firstwins.addFrameAtPath(
1516
+ path = path!!,
1517
+ qx = qx, qy = qy, qz = qz, qw = qw,
1518
+ fx = fx, fy = fy, cx = cx, cy = cy,
1519
+ imageWidth = imageWidth, imageHeight = imageHeight,
1520
+ yaw = yaw, pitch = pitch,
1521
+ fovHorizDegrees = fovHorizDegrees,
1522
+ fovVertDegrees = fovVertDegrees,
1523
+ trackingPoor = trackingPoor,
1524
+ )
1525
+ }
1429
1526
  firstwins.snapshotIfDue(tele)
1430
1527
  } else {
1431
- val tele = hybrid!!.addFrameAtPath(
1432
- path = path,
1433
- qx = qx, qy = qy, qz = qz, qw = qw,
1434
- fx = fx, fy = fy, cx = cx, cy = cy,
1435
- imageWidth = imageWidth, imageHeight = imageHeight,
1436
- yaw = yaw, pitch = pitch,
1437
- fovHorizDegrees = fovHorizDegrees,
1438
- fovVertDegrees = fovVertDegrees,
1439
- trackingPoor = trackingPoor,
1440
- )
1528
+ val tele = if (hasPixelData) {
1529
+ hybrid!!.addFramePixelData(
1530
+ nv21 = nv21PixelData!!,
1531
+ nv21Width = nv21PixelWidth,
1532
+ nv21Height = nv21PixelHeight,
1533
+ qx = qx, qy = qy, qz = qz, qw = qw,
1534
+ fx = fx, fy = fy, cx = cx, cy = cy,
1535
+ imageWidth = imageWidth, imageHeight = imageHeight,
1536
+ yaw = yaw, pitch = pitch,
1537
+ fovHorizDegrees = fovHorizDegrees,
1538
+ fovVertDegrees = fovVertDegrees,
1539
+ trackingPoor = trackingPoor,
1540
+ )
1541
+ } else {
1542
+ hybrid!!.addFrameAtPath(
1543
+ path = path!!,
1544
+ qx = qx, qy = qy, qz = qz, qw = qw,
1545
+ fx = fx, fy = fy, cx = cx, cy = cy,
1546
+ imageWidth = imageWidth, imageHeight = imageHeight,
1547
+ yaw = yaw, pitch = pitch,
1548
+ fovHorizDegrees = fovHorizDegrees,
1549
+ fovVertDegrees = fovVertDegrees,
1550
+ trackingPoor = trackingPoor,
1551
+ )
1552
+ }
1441
1553
  hybrid.snapshotIfDue(tele)
1442
1554
  }
1443
1555
  emitState(state)
1444
1556
  }
1445
1557
  }
1446
1558
 
1559
+ // ─── F8.4 — Frame Processor entry point ──────────────────────
1560
+ //
1561
+ // `consumeFrameFromPlugin` is the producer-thread ingress for
1562
+ // the vision-camera Frame Processor plugin
1563
+ // (`CvFlowGateFrameProcessor`). It takes a live
1564
+ // `android.media.Image` (held open by vision-camera for the
1565
+ // duration of the plugin callback) plus pose primitives, and
1566
+ // delegates to the existing `ingestFromARCameraView` after
1567
+ // extracting the Y plane bytes and wiring an inline JPEG
1568
+ // encoder for the on-accept lambda.
1569
+ //
1570
+ // ## Why this lives here (not on the plugin class)
1571
+ //
1572
+ // The plugin needs zero knowledge of the engine's internals
1573
+ // (batchKeyframeMode, eval-throttling, plane-latching, etc.)
1574
+ // — that's all in `ingestFromARCameraView`. Mirroring iOS'
1575
+ // `consumeFrameFromPlugin`, the wrapper just maps the public
1576
+ // primitive contract to the existing engine entry point.
1577
+ //
1578
+ // ## Why pass `Image` (not just the Y bytes)
1579
+ //
1580
+ // The engine's `ingestFromARCameraView` uses Y-only for the
1581
+ // keyframe gate. But when the gate ACCEPTS, the host (us) is
1582
+ // responsible for encoding the accepted frame as JPEG before
1583
+ // `ingestFromARCameraView` returns. YuvImage / NV21 needs the
1584
+ // full Y + interleaved VU planes, so we keep the Image
1585
+ // reachable through the lambda. Image's lifetime is bounded
1586
+ // by the plugin callback's return — vision-camera closes the
1587
+ // ImageProxy automatically — so the encode MUST be synchronous.
1588
+ //
1589
+ // ## Threading
1590
+ //
1591
+ // Called on vision-camera's frame-processor thread (a single-
1592
+ // thread executor). `frameProcessorIngestEnabled` is read
1593
+ // lock-free via AtomicBoolean. `ingestFromARCameraView`
1594
+ // dispatches the heavy engine work to `workScope` (serial),
1595
+ // so producer-thread blocking is bounded to the synchronous
1596
+ // gate evaluation + (on accept) JPEG encode — typically
1597
+ // 5–10 ms reject, 30–50 ms accept on a mid-tier device.
1598
+ fun consumeFrameFromPlugin(
1599
+ image: android.media.Image,
1600
+ tx: Double, ty: Double, tz: Double,
1601
+ qx: Double, qy: Double, qz: Double, qw: Double,
1602
+ fx: Double, fy: Double, cx: Double, cy: Double,
1603
+ timestampMs: Double,
1604
+ trackingStateRaw: Int,
1605
+ // F8.4-Android-c rotation fix: how many degrees the sensor
1606
+ // data needs to be rotated CW to display upright. Comes
1607
+ // from vision-camera's `Frame.imageProxy.imageInfo.rotationDegrees`.
1608
+ // Typically 90 for a portrait-held back camera on Samsung
1609
+ // devices (sensor mounted 90° rotated from screen-up).
1610
+ sensorRotationDegrees: Int,
1611
+ ) {
1612
+ // F8.4 — drop the call unless this capture was started in
1613
+ // frameProcessor mode. Otherwise the plugin would double-
1614
+ // feed the engine alongside the legacy jsDriver /
1615
+ // processFrameAtPath path. See the flag's declaration
1616
+ // for the full reasoning. Mirrors iOS H1.
1617
+ if (!frameProcessorIngestEnabled.get()) return
1618
+
1619
+ // F8.6 — pack the full NV21 (Y + interleaved VU) once,
1620
+ // then reuse it for BOTH the gate's Y-plane read AND the
1621
+ // live engine's pixel-data ingest. Previously the plugin
1622
+ // only extracted Y; the live engine then had to JPEG-decode
1623
+ // a separately-encoded path to recover BGR colour. Now we
1624
+ // skip both round-trips: the packed NV21 → BGR cvtColor
1625
+ // inside `addFramePixelData` produces the BGR Mat directly.
1626
+ //
1627
+ // YuvImageConverter.packNV21 is stride-aware and densely
1628
+ // repacks Y (so the gate's `grayStride = grayWidth = width`
1629
+ // works), then interleaves VU per the standard NV21 layout
1630
+ // [Y...][VU...]. Returns null only on degenerate Images
1631
+ // (closed mid-callback or non-YUV format).
1632
+ val packed = io.imagestitcher.rn.ar.YuvImageConverter.packNV21(image)
1633
+ ?: return
1634
+ val width = packed.width
1635
+ val height = packed.height
1636
+ val nv21Bytes = packed.nv21
1637
+ // The gate reads `grayHeight` rows of `grayWidth` pixels
1638
+ // at stride=width starting from offset 0. That's exactly
1639
+ // the Y plane region of nv21Bytes — the gate naturally
1640
+ // stops before the UV bytes start. No need to slice into
1641
+ // a separate ByteArray.
1642
+ val yBytes = nv21Bytes
1643
+ val yRowStride = width
1644
+
1645
+ // Compute derived params expected by the existing ingest
1646
+ // API. Quaternion-to-yaw/pitch follows the same convention
1647
+ // useFrameProcessorDriver synthesises on JS (q_yaw * q_pitch).
1648
+ //
1649
+ // yaw = atan2(2(qw*qy + qx*qz), 1 - 2(qy² + qz²))
1650
+ // pitch = asin(clamp(2(qw*qx - qz*qy), -1, 1))
1651
+ val yaw = kotlin.math.atan2(
1652
+ 2.0 * (qw * qy + qx * qz),
1653
+ 1.0 - 2.0 * (qy * qy + qz * qz),
1654
+ )
1655
+ val pitch = kotlin.math.asin(
1656
+ (2.0 * (qw * qx - qz * qy)).coerceIn(-1.0, 1.0),
1657
+ )
1658
+
1659
+ // FoV from intrinsics + dims. fx == 0 is the "JS didn't
1660
+ // supply" signal (the iOS wrapper has the same default);
1661
+ // fall back to a 65°×50° estimate so the engine doesn't
1662
+ // see NaN.
1663
+ val fovHorizDegrees = if (fx > 0.0)
1664
+ 2.0 * kotlin.math.atan(width.toDouble() / (2.0 * fx)) * 180.0 / Math.PI
1665
+ else 65.0
1666
+ val fovVertDegrees = if (fy > 0.0)
1667
+ 2.0 * kotlin.math.atan(height.toDouble() / (2.0 * fy)) * 180.0 / Math.PI
1668
+ else 50.0
1669
+
1670
+ // `2` == `.tracking` per the iOS RNSARTrackingState enum.
1671
+ // Anything else maps to trackingPoor=true, routing the
1672
+ // frame through the engine's degraded-tracking branches
1673
+ // (failing closed; symmetric with iOS C2).
1674
+ val trackingPoor = trackingStateRaw != 2
1675
+
1676
+ ingestFromARCameraView(
1677
+ tx = tx, ty = ty, tz = tz,
1678
+ qx = qx, qy = qy, qz = qz, qw = qw,
1679
+ fx = fx, fy = fy, cx = cx, cy = cy,
1680
+ imageWidth = width, imageHeight = height,
1681
+ yaw = yaw, pitch = pitch,
1682
+ fovHorizDegrees = fovHorizDegrees,
1683
+ fovVertDegrees = fovVertDegrees,
1684
+ trackingPoor = trackingPoor,
1685
+ grayData = yBytes,
1686
+ grayWidth = width,
1687
+ grayHeight = height,
1688
+ grayStride = yRowStride,
1689
+ // F8.6 — pass the already-packed NV21 so the live
1690
+ // engine branch (hybrid / firstwins) can ingest via
1691
+ // `addFramePixelData` instead of JPEG-decoding a
1692
+ // separately-written path. Batch-keyframe mode
1693
+ // ignores these (it uses `grayData` + `onAccept`).
1694
+ nv21PixelData = nv21Bytes,
1695
+ nv21PixelWidth = width,
1696
+ nv21PixelHeight = height,
1697
+ onAccept = { targetPath ->
1698
+ // Synchronous JPEG encode via the existing
1699
+ // YuvImageConverter (also used by RNSARCameraView's
1700
+ // ARCore path). Reuses the NV21 already packed at
1701
+ // the top of `consumeFrameFromPlugin` — F8.6 saves
1702
+ // a duplicate packNV21 call here (the previous
1703
+ // version repacked the live `image` inside the
1704
+ // lambda).
1705
+ //
1706
+ // EXIF rotation is BAKED-AS-METADATA, not pixel-
1707
+ // rotated. cv::imread in the stitcher ignores EXIF
1708
+ // by default (see BatchStitcher.applyExifOrientation),
1709
+ // so the engine's stored `frameRotationDegrees` still
1710
+ // governs how the cv::Mat is interpreted downstream.
1711
+ // No double-rotation.
1712
+ //
1713
+ // Returning `true` tells the engine the keyframe was
1714
+ // persisted; `false` tells it to drop the accept.
1715
+ try {
1716
+ val displayRotation = when (sensorRotationDegrees) {
1717
+ 0 -> android.view.Surface.ROTATION_90
1718
+ 90 -> android.view.Surface.ROTATION_0
1719
+ 180 -> android.view.Surface.ROTATION_270
1720
+ 270 -> android.view.Surface.ROTATION_180
1721
+ else -> android.view.Surface.ROTATION_0
1722
+ }
1723
+ val outPath = YuvImageConverter.encodeJpegFromNV21(
1724
+ packed,
1725
+ targetPath,
1726
+ jpegQuality = 80,
1727
+ displayRotation = displayRotation,
1728
+ )
1729
+ outPath != null
1730
+ } catch (e: Throwable) {
1731
+ android.util.Log.w(
1732
+ "IncrementalStitcher",
1733
+ "consumeFrameFromPlugin: JPEG encode failed for $targetPath: ${e.javaClass.simpleName}: ${e.message}",
1734
+ e,
1735
+ )
1736
+ false
1737
+ }
1738
+ },
1739
+ )
1740
+ }
1741
+
1447
1742
  @ReactMethod
1448
1743
  fun getState(promise: Promise) {
1449
1744
  val state = firstwinsEngine?.lastState ?: engine?.lastState
@@ -1944,6 +2239,12 @@ class IncrementalStitcher(
1944
2239
  // Ignore — not critical at teardown.
1945
2240
  }
1946
2241
  captureSessionDir = null
2242
+ // F8.4 — release the static back-pointer so the Frame
2243
+ // Processor plugin sees a clean nil after bridge teardown.
2244
+ // A new bridge will set it again via the init block.
2245
+ if (bridgeInstance === this) {
2246
+ bridgeInstance = null
2247
+ }
1947
2248
  super.onCatalystInstanceDestroy()
1948
2249
  }
1949
2250
 
@@ -2334,7 +2635,114 @@ internal class IncrementalEngine(
2334
2635
  // See iOS' equivalent fix for the architectural rationale.
2335
2636
  val frame = downsampleToCompose(srcRaw)
2336
2637
  if (frame !== srcRaw) srcRaw.release()
2638
+ val tele = addFrameMat(
2639
+ frame,
2640
+ qx, qy, qz, qw,
2641
+ fx, fy, cx, cy,
2642
+ imageWidth, imageHeight,
2643
+ yaw, pitch,
2644
+ fovHorizDegrees, fovVertDegrees,
2645
+ t0,
2646
+ )
2647
+ f8_6_logPerf("hybrid/jpeg", t0, tele.outcome)
2648
+ return tele
2649
+ }
2650
+
2651
+ /**
2652
+ * F8.6 — pixel-data twin of [addFrameAtPath]. Accepts the
2653
+ * camera frame as an NV21 byte buffer instead of a JPEG file
2654
+ * path; skips the JPEG decode round-trip. See
2655
+ * `IncrementalFirstwinsEngine.addFramePixelData` for the
2656
+ * sibling implementation rationale.
2657
+ */
2658
+ fun addFramePixelData(
2659
+ nv21: ByteArray,
2660
+ nv21Width: Int,
2661
+ nv21Height: Int,
2662
+ qx: Double, qy: Double, qz: Double, qw: Double,
2663
+ fx: Double, fy: Double, cx: Double, cy: Double,
2664
+ imageWidth: Int, imageHeight: Int,
2665
+ yaw: Double, pitch: Double,
2666
+ fovHorizDegrees: Double, fovVertDegrees: Double,
2667
+ trackingPoor: Boolean,
2668
+ ): FrameTelemetry {
2669
+ val t0 = System.nanoTime()
2670
+ if (trackingPoor) {
2671
+ return FrameTelemetry(
2672
+ FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
2673
+ msSince(t0),
2674
+ )
2675
+ }
2676
+ // F8.6 IS-1 — length guard; see
2677
+ // `IncrementalFirstwinsEngine.addFramePixelData` for the
2678
+ // failure-mode rationale.
2679
+ val expectedBytes = nv21Width * nv21Height * 3 / 2
2680
+ require(nv21.size >= expectedBytes) {
2681
+ "addFramePixelData: nv21 buffer too small " +
2682
+ "(${nv21.size} bytes < $expectedBytes for " +
2683
+ "${nv21Width}x${nv21Height})"
2684
+ }
2685
+ val yuv = Mat(nv21Height + nv21Height / 2, nv21Width, CvType.CV_8UC1)
2686
+ yuv.put(0, 0, nv21)
2687
+ val srcRaw = Mat()
2688
+ Imgproc.cvtColor(yuv, srcRaw, Imgproc.COLOR_YUV2BGR_NV21)
2689
+ yuv.release()
2690
+ if (srcRaw.empty()) {
2691
+ return FrameTelemetry(
2692
+ FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
2693
+ msSince(t0),
2694
+ )
2695
+ }
2696
+ val frame = downsampleToCompose(srcRaw)
2697
+ if (frame !== srcRaw) srcRaw.release()
2698
+ val tele = addFrameMat(
2699
+ frame,
2700
+ qx, qy, qz, qw,
2701
+ fx, fy, cx, cy,
2702
+ imageWidth, imageHeight,
2703
+ yaw, pitch,
2704
+ fovHorizDegrees, fovVertDegrees,
2705
+ t0,
2706
+ )
2707
+ f8_6_logPerf("hybrid/pixel", t0, tele.outcome)
2708
+ return tele
2709
+ }
2337
2710
 
2711
+ /**
2712
+ * F8.6 perf-diagnostic counter (mirror of the firstwins one).
2713
+ * Remove after v0.5.1 ships and the numbers are baked in.
2714
+ */
2715
+ @Volatile private var f8_6_perfCallCounter: Long = 0L
2716
+ private fun f8_6_logPerf(
2717
+ path: String,
2718
+ t0Nanos: Long,
2719
+ outcome: FrameOutcome,
2720
+ ) {
2721
+ val n = ++f8_6_perfCallCounter
2722
+ if (n == 1L || n % 5L == 0L) {
2723
+ android.util.Log.i(
2724
+ "F8.6-perf",
2725
+ "$path took ${msSince(t0Nanos)}ms outcome=$outcome (call #$n)",
2726
+ )
2727
+ }
2728
+ }
2729
+
2730
+ /**
2731
+ * F8.6 — the body extracted from [addFrameAtPath]. Takes a
2732
+ * BGR `Mat` (already downsampled to compose dims) and runs the
2733
+ * pose-driven homography paste pipeline. Behaviour is
2734
+ * identical to the pre-F8.6 `addFrameAtPath` — the body is a
2735
+ * verbatim move.
2736
+ */
2737
+ private fun addFrameMat(
2738
+ frame: Mat,
2739
+ qx: Double, qy: Double, qz: Double, qw: Double,
2740
+ fx: Double, fy: Double, cx: Double, cy: Double,
2741
+ imageWidth: Int, imageHeight: Int,
2742
+ yaw: Double, pitch: Double,
2743
+ fovHorizDegrees: Double, fovVertDegrees: Double,
2744
+ t0: Long,
2745
+ ): FrameTelemetry {
2338
2746
  // Build R_new from quaternion.
2339
2747
  val rNew = quaternionToRotationMat(qx, qy, qz, qw)
2340
2748
 
@@ -5,6 +5,7 @@ import com.facebook.react.ReactPackage
5
5
  import com.facebook.react.bridge.NativeModule
6
6
  import com.facebook.react.bridge.ReactApplicationContext
7
7
  import com.facebook.react.uimanager.ViewManager
8
+ import com.mrousavy.camera.frameprocessors.FrameProcessorPluginRegistry
8
9
 
9
10
  /**
10
11
  * ReactPackage that registers the SDK's two native modules with
@@ -22,15 +23,72 @@ import com.facebook.react.uimanager.ViewManager
22
23
  * JS layer.
23
24
  */
24
25
  class RNImageStitcherPackage : ReactPackage {
26
+
27
+ companion object {
28
+ @Volatile
29
+ private var fpPluginRegistered = false
30
+
31
+ /**
32
+ * F8.4 — register the vision-camera Frame Processor plugin.
33
+ * Called lazily from `createNativeModules` (which fires
34
+ * AFTER the React bridge has booted, side-stepping the
35
+ * bridgeless TurboModule init race we'd hit if we did this
36
+ * in a class-level static initialiser).
37
+ *
38
+ * No-op when vision-camera isn't on the runtime classpath
39
+ * (the SDK doesn't hard-depend on it — consumers that don't
40
+ * use `<Camera>` don't pay the dep). Catches
41
+ * `NoClassDefFoundError` defensively because the runtime
42
+ * classpath is what matters, not the compile-time one.
43
+ *
44
+ * Idempotent: guarded by `fpPluginRegistered` so a host
45
+ * with multiple React instances doesn't double-register
46
+ * (would throw "name already exists" from the registry).
47
+ */
48
+ @JvmStatic
49
+ @Synchronized
50
+ fun ensureFrameProcessorPluginRegistered() {
51
+ if (fpPluginRegistered) return
52
+ try {
53
+ FrameProcessorPluginRegistry.addFrameProcessorPlugin(
54
+ "cv_flow_gate_process_frame",
55
+ ) { proxy, options ->
56
+ CvFlowGateFrameProcessor(proxy, options)
57
+ }
58
+ fpPluginRegistered = true
59
+ } catch (e: NoClassDefFoundError) {
60
+ android.util.Log.i(
61
+ "RNImageStitcherPackage",
62
+ "vision-camera FrameProcessorPluginRegistry not on classpath — "
63
+ + "skipping cv_flow_gate_process_frame plugin registration "
64
+ + "(host app doesn't appear to use Frame Processors).",
65
+ )
66
+ fpPluginRegistered = true // don't retry every package init
67
+ } catch (e: Throwable) {
68
+ android.util.Log.w(
69
+ "RNImageStitcherPackage",
70
+ "Failed to register cv_flow_gate_process_frame plugin: ${e.message}",
71
+ )
72
+ fpPluginRegistered = true
73
+ }
74
+ }
75
+ }
76
+
25
77
  override fun createNativeModules(
26
78
  reactContext: ReactApplicationContext,
27
- ): List<NativeModule> = listOf(
28
- QualityChecker(reactContext),
29
- BatchStitcher(reactContext),
30
- RNSARSession(reactContext),
31
- IncrementalStitcher(reactContext),
32
- FileBridge(reactContext),
33
- )
79
+ ): List<NativeModule> {
80
+ // F8.4 — register the Frame Processor plugin here, after the
81
+ // bridge is fully booted. See `ensureFrameProcessorPluginRegistered`
82
+ // for the rationale (vs. a class-load-time static init).
83
+ ensureFrameProcessorPluginRegistered()
84
+ return listOf(
85
+ QualityChecker(reactContext),
86
+ BatchStitcher(reactContext),
87
+ RNSARSession(reactContext),
88
+ IncrementalStitcher(reactContext),
89
+ FileBridge(reactContext),
90
+ )
91
+ }
34
92
 
35
93
  override fun createViewManagers(
36
94
  reactContext: ReactApplicationContext,