react-native-image-stitcher 0.1.0

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.
Files changed (151) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +21 -0
  4. package/README.md +189 -0
  5. package/RNImageStitcher.podspec +76 -0
  6. package/android/build.gradle +224 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/CMakeLists.txt +124 -0
  9. package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
  10. package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
  11. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
  12. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
  13. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
  14. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
  15. package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
  16. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
  17. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
  18. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
  19. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
  20. package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
  21. package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
  22. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
  23. package/cpp/ar_frame_pose.h +63 -0
  24. package/cpp/keyframe_gate.cpp +927 -0
  25. package/cpp/keyframe_gate.hpp +240 -0
  26. package/cpp/stitcher.cpp +2207 -0
  27. package/cpp/stitcher.hpp +275 -0
  28. package/dist/ar/useARSession.d.ts +102 -0
  29. package/dist/ar/useARSession.js +133 -0
  30. package/dist/camera/ARCameraView.d.ts +93 -0
  31. package/dist/camera/ARCameraView.js +170 -0
  32. package/dist/camera/Camera.d.ts +134 -0
  33. package/dist/camera/Camera.js +688 -0
  34. package/dist/camera/CameraShutter.d.ts +80 -0
  35. package/dist/camera/CameraShutter.js +237 -0
  36. package/dist/camera/CameraView.d.ts +65 -0
  37. package/dist/camera/CameraView.js +117 -0
  38. package/dist/camera/CaptureControlsBar.d.ts +87 -0
  39. package/dist/camera/CaptureControlsBar.js +82 -0
  40. package/dist/camera/CaptureHeader.d.ts +62 -0
  41. package/dist/camera/CaptureHeader.js +81 -0
  42. package/dist/camera/CapturePreview.d.ts +70 -0
  43. package/dist/camera/CapturePreview.js +188 -0
  44. package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
  45. package/dist/camera/CaptureStatusOverlay.js +326 -0
  46. package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
  47. package/dist/camera/CaptureThumbnailStrip.js +177 -0
  48. package/dist/camera/IncrementalPanGuide.d.ts +83 -0
  49. package/dist/camera/IncrementalPanGuide.js +267 -0
  50. package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
  51. package/dist/camera/PanoramaBandOverlay.js +399 -0
  52. package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
  53. package/dist/camera/PanoramaConfirmModal.js +128 -0
  54. package/dist/camera/PanoramaGuidance.d.ts +79 -0
  55. package/dist/camera/PanoramaGuidance.js +246 -0
  56. package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
  57. package/dist/camera/PanoramaSettingsModal.js +611 -0
  58. package/dist/camera/ViewportCropOverlay.d.ts +46 -0
  59. package/dist/camera/ViewportCropOverlay.js +67 -0
  60. package/dist/camera/useCapture.d.ts +111 -0
  61. package/dist/camera/useCapture.js +160 -0
  62. package/dist/camera/useDeviceOrientation.d.ts +48 -0
  63. package/dist/camera/useDeviceOrientation.js +131 -0
  64. package/dist/camera/useVideoCapture.d.ts +79 -0
  65. package/dist/camera/useVideoCapture.js +151 -0
  66. package/dist/index.d.ts +26 -0
  67. package/dist/index.js +39 -0
  68. package/dist/quality/normaliseOrientation.d.ts +36 -0
  69. package/dist/quality/normaliseOrientation.js +62 -0
  70. package/dist/quality/runQualityCheck.d.ts +41 -0
  71. package/dist/quality/runQualityCheck.js +98 -0
  72. package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
  73. package/dist/sensors/useIMUTranslationGate.js +235 -0
  74. package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
  75. package/dist/stitching/IncrementalStitcherView.js +157 -0
  76. package/dist/stitching/incremental.d.ts +930 -0
  77. package/dist/stitching/incremental.js +133 -0
  78. package/dist/stitching/stitchFrames.d.ts +55 -0
  79. package/dist/stitching/stitchFrames.js +56 -0
  80. package/dist/stitching/stitchVideo.d.ts +119 -0
  81. package/dist/stitching/stitchVideo.js +57 -0
  82. package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
  83. package/dist/stitching/useIncrementalJSDriver.js +199 -0
  84. package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
  85. package/dist/stitching/useIncrementalStitcher.js +172 -0
  86. package/dist/types.d.ts +58 -0
  87. package/dist/types.js +15 -0
  88. package/ios/Package.swift +72 -0
  89. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
  90. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
  91. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
  92. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
  93. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
  94. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
  95. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
  96. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
  97. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
  98. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
  99. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
  101. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
  102. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
  105. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
  106. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
  107. package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
  108. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
  109. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
  110. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
  111. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
  112. package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
  113. package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
  114. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
  115. package/package.json +73 -0
  116. package/react-native.config.js +34 -0
  117. package/scripts/opencv-version.txt +1 -0
  118. package/scripts/postinstall-fetch-binaries.js +286 -0
  119. package/src/ar/useARSession.ts +210 -0
  120. package/src/camera/.gitkeep +0 -0
  121. package/src/camera/ARCameraView.tsx +256 -0
  122. package/src/camera/Camera.tsx +1053 -0
  123. package/src/camera/CameraShutter.tsx +292 -0
  124. package/src/camera/CameraView.tsx +157 -0
  125. package/src/camera/CaptureControlsBar.tsx +204 -0
  126. package/src/camera/CaptureHeader.tsx +184 -0
  127. package/src/camera/CapturePreview.tsx +318 -0
  128. package/src/camera/CaptureStatusOverlay.tsx +391 -0
  129. package/src/camera/CaptureThumbnailStrip.tsx +277 -0
  130. package/src/camera/IncrementalPanGuide.tsx +328 -0
  131. package/src/camera/PanoramaBandOverlay.tsx +498 -0
  132. package/src/camera/PanoramaConfirmModal.tsx +206 -0
  133. package/src/camera/PanoramaGuidance.tsx +327 -0
  134. package/src/camera/PanoramaSettingsModal.tsx +1357 -0
  135. package/src/camera/ViewportCropOverlay.tsx +81 -0
  136. package/src/camera/useCapture.ts +279 -0
  137. package/src/camera/useDeviceOrientation.ts +140 -0
  138. package/src/camera/useVideoCapture.ts +236 -0
  139. package/src/index.ts +53 -0
  140. package/src/quality/.gitkeep +0 -0
  141. package/src/quality/normaliseOrientation.ts +79 -0
  142. package/src/quality/runQualityCheck.ts +131 -0
  143. package/src/sensors/useIMUTranslationGate.ts +347 -0
  144. package/src/stitching/.gitkeep +0 -0
  145. package/src/stitching/IncrementalStitcherView.tsx +198 -0
  146. package/src/stitching/incremental.ts +1021 -0
  147. package/src/stitching/stitchFrames.ts +88 -0
  148. package/src/stitching/stitchVideo.ts +153 -0
  149. package/src/stitching/useIncrementalJSDriver.ts +273 -0
  150. package/src/stitching/useIncrementalStitcher.ts +252 -0
  151. package/src/types.ts +78 -0
@@ -0,0 +1,176 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn.ar
3
+
4
+ import android.opengl.GLES11Ext
5
+ import android.opengl.GLES20
6
+ import com.google.ar.core.Coordinates2d
7
+ import com.google.ar.core.Frame
8
+ import com.google.ar.core.Session
9
+ import java.nio.ByteBuffer
10
+ import java.nio.ByteOrder
11
+ import java.nio.FloatBuffer
12
+
13
+ /**
14
+ * Renders the ARCore camera feed as a fullscreen background quad.
15
+ *
16
+ * ARCore delivers camera frames via a GL_TEXTURE_EXTERNAL_OES texture
17
+ * (the OpenGL ES extension that lets a SurfaceTexture be sampled
18
+ * directly). We bind that texture to the session via
19
+ * `Session.setCameraTextureName()` and render it through the
20
+ * fullscreen quad below; ARCore handles the camera pixel updates
21
+ * inside `Session.update()`.
22
+ *
23
+ * Why hand-rolled vs adopting the Google hello_ar sample:
24
+ * The sample is ~250 lines per renderer plus shader files in
25
+ * `assets/shaders/`. We only need a fullscreen quad — vertex
26
+ * coords (-1..1) plus texture coords supplied by ARCore via
27
+ * `Frame.transformCoordinates2d()`. That fits in ~150 lines
28
+ * inline, no asset dependency, easier to debug.
29
+ */
30
+ internal class BackgroundRenderer {
31
+
32
+ /// OES texture id ARCore writes camera pixels into.
33
+ var textureId: Int = -1
34
+ private set
35
+
36
+ /// Compiled shader program.
37
+ private var program: Int = 0
38
+ /// Attribute / uniform locations cached at link time.
39
+ private var positionAttrib: Int = 0
40
+ private var texCoordAttrib: Int = 0
41
+ private var textureUniform: Int = 0
42
+
43
+ /// Static fullscreen quad coords.
44
+ private val quadCoords: FloatBuffer = ByteBuffer
45
+ .allocateDirect(QUAD_COORDS.size * 4)
46
+ .order(ByteOrder.nativeOrder())
47
+ .asFloatBuffer()
48
+ .apply {
49
+ put(QUAD_COORDS)
50
+ position(0)
51
+ }
52
+
53
+ /// Texture coords ARCore overwrites each frame via
54
+ /// `Frame.transformCoordinates2d()`. The buffer is allocated
55
+ /// once and refilled in-place to avoid GC pressure.
56
+ private val quadTexCoords: FloatBuffer = ByteBuffer
57
+ .allocateDirect(QUAD_COORDS.size * 4)
58
+ .order(ByteOrder.nativeOrder())
59
+ .asFloatBuffer()
60
+
61
+ /// Has `Session.setCameraTextureName()` been called for the
62
+ /// current GL context? `BackgroundRenderer` is created on the
63
+ /// GL render thread but the Session may already exist; we
64
+ /// connect them on the first draw call.
65
+ private var sessionTextureBound: Boolean = false
66
+
67
+ fun createOnGlThread() {
68
+ // Create OES texture for ARCore's camera output.
69
+ val textures = IntArray(1)
70
+ GLES20.glGenTextures(1, textures, 0)
71
+ textureId = textures[0]
72
+ val target = GLES11Ext.GL_TEXTURE_EXTERNAL_OES
73
+ GLES20.glBindTexture(target, textureId)
74
+ GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
75
+ GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
76
+ GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
77
+ GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
78
+
79
+ program = ShaderUtil.loadGLProgram(VERTEX_SHADER, FRAGMENT_SHADER)
80
+ positionAttrib = GLES20.glGetAttribLocation(program, "a_Position")
81
+ texCoordAttrib = GLES20.glGetAttribLocation(program, "a_TexCoord")
82
+ textureUniform = GLES20.glGetUniformLocation(program, "sTexture")
83
+ ShaderUtil.checkGLError("BackgroundRenderer.createOnGlThread")
84
+ }
85
+
86
+ fun bindToSession(session: Session) {
87
+ if (textureId == -1) return
88
+ session.setCameraTextureName(textureId)
89
+ sessionTextureBound = true
90
+ }
91
+
92
+ /// Draw the camera feed for the given ARFrame. Must be called
93
+ /// from `Renderer.onDrawFrame` after `Session.update()`. No-op
94
+ /// if the GL program isn't ready yet (very first frame).
95
+ fun draw(frame: Frame) {
96
+ if (program == 0) return
97
+ // Update the tex coords from the ARFrame whenever the display
98
+ // geometry has changed (rotation, surface resize). Cheap
99
+ // (constant 8 floats) so we do it every frame.
100
+ if (frame.hasDisplayGeometryChanged()) {
101
+ frame.transformCoordinates2d(
102
+ Coordinates2d.OPENGL_NORMALIZED_DEVICE_COORDINATES,
103
+ quadCoords,
104
+ Coordinates2d.TEXTURE_NORMALIZED,
105
+ quadTexCoords,
106
+ )
107
+ }
108
+ // First frame after onSurfaceCreated: do an unconditional fill
109
+ // so we have valid tex coords before geometryChanged ever flips.
110
+ if (quadTexCoords.position() == 0 && !frame.hasDisplayGeometryChanged()) {
111
+ frame.transformCoordinates2d(
112
+ Coordinates2d.OPENGL_NORMALIZED_DEVICE_COORDINATES,
113
+ quadCoords,
114
+ Coordinates2d.TEXTURE_NORMALIZED,
115
+ quadTexCoords,
116
+ )
117
+ }
118
+ quadCoords.position(0)
119
+ quadTexCoords.position(0)
120
+
121
+ GLES20.glDisable(GLES20.GL_DEPTH_TEST)
122
+ GLES20.glDepthMask(false)
123
+ GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
124
+ GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
125
+ GLES20.glUseProgram(program)
126
+ GLES20.glUniform1i(textureUniform, 0)
127
+
128
+ GLES20.glVertexAttribPointer(positionAttrib, 2, GLES20.GL_FLOAT, false, 0, quadCoords)
129
+ GLES20.glEnableVertexAttribArray(positionAttrib)
130
+ GLES20.glVertexAttribPointer(texCoordAttrib, 2, GLES20.GL_FLOAT, false, 0, quadTexCoords)
131
+ GLES20.glEnableVertexAttribArray(texCoordAttrib)
132
+
133
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
134
+
135
+ GLES20.glDisableVertexAttribArray(positionAttrib)
136
+ GLES20.glDisableVertexAttribArray(texCoordAttrib)
137
+ GLES20.glDepthMask(true)
138
+ GLES20.glEnable(GLES20.GL_DEPTH_TEST)
139
+ ShaderUtil.checkGLError("BackgroundRenderer.draw")
140
+ }
141
+
142
+ companion object {
143
+ // Fullscreen quad in NDC space. 4 vertices, GL_TRIANGLE_STRIP
144
+ // order: bottom-left, bottom-right, top-left, top-right.
145
+ private val QUAD_COORDS = floatArrayOf(
146
+ -1f, -1f,
147
+ 1f, -1f,
148
+ -1f, 1f,
149
+ 1f, 1f,
150
+ )
151
+
152
+ // Vertex shader: pass-through. Tex coords are pre-transformed
153
+ // into TEXTURE_NORMALIZED space by ARCore so no extra matrix
154
+ // math is needed.
155
+ private val VERTEX_SHADER = """
156
+ attribute vec4 a_Position;
157
+ attribute vec2 a_TexCoord;
158
+ varying vec2 v_TexCoord;
159
+ void main() {
160
+ gl_Position = a_Position;
161
+ v_TexCoord = a_TexCoord;
162
+ }
163
+ """.trimIndent()
164
+
165
+ // Fragment shader: sample the OES external texture and write.
166
+ private val FRAGMENT_SHADER = """
167
+ #extension GL_OES_EGL_image_external : require
168
+ precision mediump float;
169
+ uniform samplerExternalOES sTexture;
170
+ varying vec2 v_TexCoord;
171
+ void main() {
172
+ gl_FragColor = texture2D(sTexture, v_TexCoord);
173
+ }
174
+ """.trimIndent()
175
+ }
176
+ }
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn.ar
3
+
4
+ import android.opengl.GLES20
5
+
6
+ /**
7
+ * Minimal GLSL compile + link helper. Shared by the BackgroundRenderer
8
+ * (and any future GL stages we add). Keeps the per-renderer code
9
+ * focused on what's drawn rather than on GLSL boilerplate.
10
+ *
11
+ * No Android Studio or hello_ar dependency — small enough to inline.
12
+ */
13
+ internal object ShaderUtil {
14
+
15
+ /**
16
+ * Compile a vertex + fragment shader pair and link into a GL
17
+ * program. Throws on any compile/link error with the GL log
18
+ * attached to the message — much more useful than the bare
19
+ * GL_FALSE return that GLES20 hands you.
20
+ */
21
+ fun loadGLProgram(vertexSrc: String, fragmentSrc: String): Int {
22
+ val vsh = compileShader(GLES20.GL_VERTEX_SHADER, vertexSrc)
23
+ val fsh = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSrc)
24
+ val program = GLES20.glCreateProgram()
25
+ GLES20.glAttachShader(program, vsh)
26
+ GLES20.glAttachShader(program, fsh)
27
+ GLES20.glLinkProgram(program)
28
+ val linked = IntArray(1)
29
+ GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linked, 0)
30
+ if (linked[0] == 0) {
31
+ val log = GLES20.glGetProgramInfoLog(program)
32
+ GLES20.glDeleteProgram(program)
33
+ throw RuntimeException("Shader link failed: $log")
34
+ }
35
+ // Shaders can be detached + deleted once linked; the program
36
+ // holds its own reference to the compiled object code.
37
+ GLES20.glDetachShader(program, vsh)
38
+ GLES20.glDetachShader(program, fsh)
39
+ GLES20.glDeleteShader(vsh)
40
+ GLES20.glDeleteShader(fsh)
41
+ return program
42
+ }
43
+
44
+ private fun compileShader(type: Int, src: String): Int {
45
+ val shader = GLES20.glCreateShader(type)
46
+ GLES20.glShaderSource(shader, src)
47
+ GLES20.glCompileShader(shader)
48
+ val compiled = IntArray(1)
49
+ GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
50
+ if (compiled[0] == 0) {
51
+ val log = GLES20.glGetShaderInfoLog(shader)
52
+ GLES20.glDeleteShader(shader)
53
+ throw RuntimeException("Shader compile failed: $log\nSource:\n$src")
54
+ }
55
+ return shader
56
+ }
57
+
58
+ /// Wrap a GLES call with `glGetError` checking — caller passes a
59
+ /// human-readable label so the panic message tells you which call
60
+ /// died. Cheap; we only call it during init / per-frame setup.
61
+ fun checkGLError(label: String) {
62
+ val err = GLES20.glGetError()
63
+ if (err != GLES20.GL_NO_ERROR) {
64
+ throw RuntimeException("$label: glError 0x${err.toString(16)}")
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,201 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn.ar
3
+
4
+ import android.graphics.ImageFormat
5
+ import android.graphics.Rect
6
+ import android.graphics.YuvImage
7
+ import android.media.Image
8
+ import android.view.Surface
9
+ import androidx.exifinterface.media.ExifInterface
10
+ import java.io.ByteArrayOutputStream
11
+ import java.io.File
12
+ import java.io.FileOutputStream
13
+
14
+ /**
15
+ * Convert an ARCore `Image` (YUV_420_888) to a JPEG file on disk.
16
+ *
17
+ * Why JPEG → file → re-decode by OpenCV (slightly wasteful)?
18
+ * The incremental engine's existing API (matching iOS') consumes
19
+ * image PATHS, not raw planes. Threading raw YUV through the
20
+ * bridge would require a second native ingestion path. At ~3-5
21
+ * Hz of accepted frames, a ~10 ms YUV→JPEG encode is negligible
22
+ * next to the ~40 ms per-frame engine work — keeping the surface
23
+ * uniform across iOS / Android paths is worth the few-ms cost.
24
+ *
25
+ * `Image` ownership: caller MUST `image.close()` after this returns.
26
+ * We don't close inside because the caller may want to inspect more
27
+ * fields (timestamp, format) before releasing.
28
+ */
29
+ internal object YuvImageConverter {
30
+
31
+ /// Convert + write JPEG. Returns the path (no file:// prefix)
32
+ /// or null on any encode/write error (caller-decides whether to
33
+ /// log + drop the frame).
34
+ ///
35
+ /// 2026-05-15 (B3) — `displayRotation` parameter writes the
36
+ /// appropriate EXIF orientation tag so RN's Image loader (and
37
+ /// any other consumer that respects EXIF) displays the JPEG
38
+ /// upright regardless of how the device was held at capture.
39
+ ///
40
+ /// Without this, Android's image loaders display the raw sensor
41
+ /// pixels (typically landscape) as-is — so a portrait-held
42
+ /// capture's thumbnail appears sideways. iOS's image loader
43
+ /// auto-respects EXIF orientation; Android's doesn't always
44
+ /// (depends on the loader path). Setting the tag covers both.
45
+ ///
46
+ /// `displayRotation` should be the value returned from
47
+ /// `WindowManager.defaultDisplay.rotation` at capture time
48
+ /// (Surface.ROTATION_0/_90/_180/_270). We assume a typical
49
+ /// back-camera sensor orientation of 90° — true for all
50
+ /// devices we ship to (Galaxy A35 verified). Wire through
51
+ /// CameraCharacteristics.SENSOR_ORIENTATION in a follow-up
52
+ /// if we ever encounter a device that differs.
53
+ ///
54
+ /// Default `displayRotation = Surface.ROTATION_0` (portrait)
55
+ /// preserves the previous behaviour for legacy callsites that
56
+ /// haven't been updated yet.
57
+ fun encodeToJpeg(
58
+ image: Image,
59
+ outputPath: String,
60
+ jpegQuality: Int = 70,
61
+ displayRotation: Int = Surface.ROTATION_0,
62
+ ): String? {
63
+ if (image.format != ImageFormat.YUV_420_888) return null
64
+ val nv21 = yuv420toNV21(image) ?: return null
65
+ val yuvImage = YuvImage(
66
+ nv21,
67
+ ImageFormat.NV21,
68
+ image.width,
69
+ image.height,
70
+ null,
71
+ )
72
+ val baos = ByteArrayOutputStream()
73
+ val ok = yuvImage.compressToJpeg(
74
+ Rect(0, 0, image.width, image.height),
75
+ jpegQuality.coerceIn(1, 100),
76
+ baos,
77
+ )
78
+ if (!ok) return null
79
+ try {
80
+ FileOutputStream(File(outputPath)).use { it.write(baos.toByteArray()) }
81
+ } catch (e: Throwable) {
82
+ return null
83
+ }
84
+ // Write EXIF orientation tag based on display rotation.
85
+ // Sensor orientation 90° assumed (back camera). The math:
86
+ // ROTATION_0 (portrait, sensor 90° CW from screen-up)
87
+ // → JPEG needs 90° CW to display upright → ROTATE_90 (6)
88
+ // ROTATION_90 (landscape-left, sensor aligned with screen)
89
+ // → no rotation → NORMAL (1)
90
+ // ROTATION_180 (portrait-upside-down)
91
+ // → 270° CW → ROTATE_270 (8)
92
+ // ROTATION_270 (landscape-right)
93
+ // → 180° → ROTATE_180 (3)
94
+ //
95
+ // EXIF tag set EVEN when the orientation is normal — keeps
96
+ // every output JPEG self-describing for downstream
97
+ // consumers (cv::Stitcher does NOT auto-honour EXIF, see
98
+ // BatchStitcher.applyExifOrientation; this metadata
99
+ // exists primarily for the live thumbnail strip + future
100
+ // RN Image renderers).
101
+ val exifOrientation = when (displayRotation) {
102
+ Surface.ROTATION_0 -> ExifInterface.ORIENTATION_ROTATE_90
103
+ Surface.ROTATION_90 -> ExifInterface.ORIENTATION_NORMAL
104
+ Surface.ROTATION_180 -> ExifInterface.ORIENTATION_ROTATE_270
105
+ Surface.ROTATION_270 -> ExifInterface.ORIENTATION_ROTATE_180
106
+ else -> ExifInterface.ORIENTATION_NORMAL
107
+ }
108
+ try {
109
+ val exif = ExifInterface(outputPath)
110
+ exif.setAttribute(
111
+ ExifInterface.TAG_ORIENTATION,
112
+ exifOrientation.toString(),
113
+ )
114
+ exif.saveAttributes()
115
+ } catch (e: Throwable) {
116
+ // EXIF write failed — JPEG itself is still valid; just
117
+ // missing the orientation hint. Caller doesn't need to
118
+ // know — non-fatal.
119
+ }
120
+ return outputPath
121
+ }
122
+
123
+ /**
124
+ * Pack a YUV_420_888 `Image` into a contiguous NV21 byte array.
125
+ *
126
+ * The Image API exposes Y, U, V as three planes, each with its
127
+ * own row stride and pixel stride. NV21 expects a single
128
+ * contiguous buffer with Y plane first, then interleaved VU bytes
129
+ * after. The repacking handles row + pixel strides that don't
130
+ * match the dense layout.
131
+ */
132
+ private fun yuv420toNV21(image: Image): ByteArray? {
133
+ val w = image.width
134
+ val h = image.height
135
+ val ySize = w * h
136
+ val uvSize = w * h / 2
137
+
138
+ val nv21 = ByteArray(ySize + uvSize)
139
+ val planes = image.planes
140
+ if (planes.size < 3) return null
141
+
142
+ // Y plane.
143
+ val yPlane = planes[0]
144
+ val yBuf = yPlane.buffer
145
+ val yRowStride = yPlane.rowStride
146
+ if (yRowStride == w) {
147
+ yBuf.get(nv21, 0, ySize)
148
+ } else {
149
+ // Row-by-row copy when stride != width.
150
+ var dstOffset = 0
151
+ var srcOffset = 0
152
+ for (row in 0 until h) {
153
+ yBuf.position(srcOffset)
154
+ yBuf.get(nv21, dstOffset, w)
155
+ dstOffset += w
156
+ srcOffset += yRowStride
157
+ }
158
+ }
159
+
160
+ // U + V planes. YUV_420_888 has them subsampled 2:1 so each
161
+ // covers (w/2) × (h/2). Pixel stride is 1 (planar) or 2
162
+ // (semi-planar interleaved). NV21 requires interleaved VU.
163
+ val uPlane = planes[1]
164
+ val vPlane = planes[2]
165
+ val uBuf = uPlane.buffer
166
+ val vBuf = vPlane.buffer
167
+ val uRowStride = uPlane.rowStride
168
+ val uPixelStride = uPlane.pixelStride
169
+ val vRowStride = vPlane.rowStride
170
+ val vPixelStride = vPlane.pixelStride
171
+
172
+ // Most camera2 / ARCore implementations on Android already
173
+ // produce semi-planar interleaved data with pixelStride=2.
174
+ // In that case Y plane + V plane (offset by 1) form NV21
175
+ // directly with a single block copy. Detect + fast-path it.
176
+ if (uPixelStride == 2 && vPixelStride == 2 &&
177
+ uRowStride == vRowStride && uRowStride == w) {
178
+ // The V plane in NV21 layout starts at vBuf's first byte.
179
+ // Copy the entire V plane (which physically interleaves
180
+ // with U bytes since pixelStride=2 means consecutive
181
+ // bytes are V-U-V-U...).
182
+ val vBytes = vBuf.remaining().coerceAtMost(uvSize)
183
+ vBuf.get(nv21, ySize, vBytes)
184
+ return nv21
185
+ }
186
+
187
+ // Slow path — manual interleave.
188
+ var pos = ySize
189
+ val rowsUv = h / 2
190
+ val colsUv = w / 2
191
+ for (row in 0 until rowsUv) {
192
+ for (col in 0 until colsUv) {
193
+ val vIdx = row * vRowStride + col * vPixelStride
194
+ val uIdx = row * uRowStride + col * uPixelStride
195
+ nv21[pos++] = vBuf.get(vIdx)
196
+ nv21[pos++] = uBuf.get(uIdx)
197
+ }
198
+ }
199
+ return nv21
200
+ }
201
+ }
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // ar_frame_pose.h — POD struct shared between iOS and Android for AR
4
+ // frame pose data crossing the C++ boundary.
5
+ //
6
+ // Both platforms marshal their native pose representation into this
7
+ // flat struct:
8
+ // - iOS: RNSARFramePose (Swift) → KeyframeGateBridge.mm
9
+ // unmarshals Swift Doubles → C++ floats.
10
+ // - Android: RNSARFramePose (Kotlin data class) → JNI
11
+ // unmarshals JVM doubles → C++ floats.
12
+ //
13
+ // Layout MUST stay stable across both platforms. Field order /
14
+ // padding / size is checked by static_assert in keyframe_gate.cpp.
15
+ //
16
+ // Convention notes:
17
+ // - tx, ty, tz: camera origin in world coordinates (metres)
18
+ // - qx, qy, qz, qw: orientation as a unit quaternion in JPL convention
19
+ // (last-real-part, matching both ARKit's simd_quatf and ARCore's
20
+ // Pose.getRotationQuaternion())
21
+ // - fx, fy, cx, cy: pinhole intrinsics (pixels)
22
+ // - imageWidth, imageHeight: pixel dimensions of the captured frame
23
+
24
+ #pragma once
25
+ #include <cstdint>
26
+
27
+ namespace retailens {
28
+
29
+ struct Pose {
30
+ // Translation in metres (world frame).
31
+ float tx;
32
+ float ty;
33
+ float tz;
34
+ // Rotation quaternion (JPL convention: last component is real).
35
+ float qx;
36
+ float qy;
37
+ float qz;
38
+ float qw;
39
+ // Pinhole camera intrinsics (pixels).
40
+ float fx;
41
+ float fy;
42
+ float cx;
43
+ float cy;
44
+ // Image dimensions (pixels).
45
+ int32_t imageWidth;
46
+ int32_t imageHeight;
47
+ };
48
+
49
+ // 4×4 column-major rotation+translation matrix. Matches both
50
+ // simd_float4x4 (iOS) and the array returned by ARCore Pose.toMatrix(...)
51
+ // layout: columns laid out contiguously, so columns.0 is m[0..3],
52
+ // columns.1 is m[4..7], etc. Translation is the last column (m[12..15]).
53
+ //
54
+ // ARKit ARPlaneAnchor convention:
55
+ // column 0 = plane tangent X (in-plane "right")
56
+ // column 1 = plane surface normal
57
+ // column 2 = plane tangent Z (in-plane "up")
58
+ // column 3 = plane origin
59
+ struct PlaneTransform {
60
+ float m[16];
61
+ };
62
+
63
+ } // namespace retailens