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.
- package/CHANGELOG.md +165 -0
- package/README.md +1 -0
- package/android/build.gradle +33 -0
- package/android/src/main/java/io/imagestitcher/rn/CvFlowGateFrameProcessor.kt +163 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +148 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +431 -23
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +65 -7
- package/dist/camera/Camera.d.ts +68 -1
- package/dist/camera/Camera.js +102 -16
- package/dist/camera/CameraView.d.ts +17 -5
- package/dist/camera/CameraView.js +28 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -1
- package/dist/stitching/incremental.d.ts +13 -4
- package/dist/stitching/useFrameProcessorDriver.d.ts +148 -0
- package/dist/stitching/useFrameProcessorDriver.js +321 -0
- package/dist/stitching/useIncrementalJSDriver.js +21 -0
- package/ios/Package.swift +35 -21
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +188 -8
- package/ios/Sources/RNImageStitcher/KeyframeGateFrameProcessor.mm +196 -0
- package/package.json +3 -1
- package/src/camera/Camera.tsx +190 -15
- package/src/camera/CameraView.tsx +50 -0
- package/src/index.ts +12 -0
- package/src/stitching/incremental.ts +12 -3
- package/src/stitching/useFrameProcessorDriver.ts +407 -0
- package/src/stitching/useIncrementalJSDriver.ts +24 -0
|
@@ -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
|
-
|
|
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
|
|
1412
|
-
"Caller should have encoded a JPEG
|
|
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 =
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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 =
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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,
|